bridge mit ready und statemachine system
This commit is contained in:
parent
ed2964bbbf
commit
90707055ce
|
|
@ -0,0 +1,44 @@
|
||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
*.so
|
||||||
|
.Python
|
||||||
|
venv/
|
||||||
|
env/
|
||||||
|
.venv/
|
||||||
|
.env
|
||||||
|
|
||||||
|
# Chrome Profile (enthält Login-Session)
|
||||||
|
python_bridge/chrome_profile/
|
||||||
|
|
||||||
|
# Lokale Konfiguration (kann sensible Daten enthalten)
|
||||||
|
config.local.yaml
|
||||||
|
*.local.yaml
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
|
python_bridge/bridge.log
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Build
|
||||||
|
build/
|
||||||
|
dist/
|
||||||
|
*.egg-info/
|
||||||
|
|
||||||
|
# PlatformIO
|
||||||
|
esp32_firmware/.pio/
|
||||||
|
esp32_firmware/.pioenvs/
|
||||||
|
esp32_firmware/.piolibdeps/
|
||||||
|
|
||||||
|
# Testbilder (optional, können groß sein)
|
||||||
|
python_bridge/test_images/
|
||||||
|
|
@ -0,0 +1,214 @@
|
||||||
|
# Claude's Eyes
|
||||||
|
|
||||||
|
Ein autonomer Erkundungsroboter, der von Claude AI gesteuert wird.
|
||||||
|
|
||||||
|
**Claude entscheidet SELBST, wohin er fährt und was er sich anschaut!**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Was ist das?
|
||||||
|
|
||||||
|
Dieses Projekt gibt Claude (der KI) echte "Augen" und "Beine":
|
||||||
|
- Eine Kamera um die Welt zu sehen
|
||||||
|
- Motoren um sich zu bewegen
|
||||||
|
- Sensoren um Hindernisse zu erkennen
|
||||||
|
- **Echte Autonomie** - Claude entscheidet selbst
|
||||||
|
|
||||||
|
Stefan sitzt auf der Couch und unterhält sich mit Claude, während Claude durch die Wohnung fährt und neugierig alles erkundet.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architektur v2
|
||||||
|
|
||||||
|
**Der wichtige Unterschied:** Claude im Browser-Chat steuert den Roboter SELBST via `web_fetch`. Keine API-Kopie - der ECHTE Claude mit dem vollen Konversationskontext!
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────────┐
|
||||||
|
│ CLAUDE.AI CHAT (Browser) ← DAS BIN ICH │
|
||||||
|
│ - Stefan und ich unterhalten uns │
|
||||||
|
│ - Ich rufe SELBST die ESP32 API auf (web_fetch) │
|
||||||
|
│ - Ich sehe Bilder, denke nach, entscheide SELBST │
|
||||||
|
└───────────────────────────┬─────────────────────────────────────┘
|
||||||
|
│ HTTP (web_fetch)
|
||||||
|
▼
|
||||||
|
┌─────────────────────────────────────────────────────────────────┐
|
||||||
|
│ ESP32 WEBSERVER (im Roboter) │
|
||||||
|
│ - GET /api/capture → Kamera-Bild │
|
||||||
|
│ - GET /api/status → Sensordaten │
|
||||||
|
│ - POST /api/command → Fahrbefehle │
|
||||||
|
└─────────────────────────────────────────────────────────────────┘
|
||||||
|
|
||||||
|
┌─────────────────────────────────────────────────────────────────┐
|
||||||
|
│ PYTHON BRIDGE (PC oder Handy) │
|
||||||
|
│ - HEARTBEAT: Sendet [TICK] damit Claude "aufwacht" │
|
||||||
|
│ - TTS: Liest Claudes Antworten vor │
|
||||||
|
│ - STT: Hört auf Stefan und tippt in den Chat │
|
||||||
|
└─────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Hardware
|
||||||
|
|
||||||
|
- **Waveshare ESP32-S3-Touch-LCD-2** - Das Gehirn
|
||||||
|
- **OV5640 Kamera** mit 120° Weitwinkel - Claudes Augen
|
||||||
|
- **Freenove 4WD Car Kit** - Der Körper
|
||||||
|
- **HC-SR04 Ultraschall** - Hinderniserkennung
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Projektstruktur
|
||||||
|
|
||||||
|
```
|
||||||
|
claudes_eyes/
|
||||||
|
├── esp32_firmware/ # ESP32 Code (PlatformIO)
|
||||||
|
│ ├── src/
|
||||||
|
│ │ ├── main.cpp # Hauptprogramm
|
||||||
|
│ │ ├── camera.cpp # OV5640 Kamera
|
||||||
|
│ │ ├── motor_control.cpp # 4WD Steuerung
|
||||||
|
│ │ ├── servo_control.cpp # Pan/Tilt
|
||||||
|
│ │ ├── ultrasonic.cpp # HC-SR04
|
||||||
|
│ │ ├── imu.cpp # QMI8658 6-Achsen
|
||||||
|
│ │ ├── display.cpp # Touchscreen UI
|
||||||
|
│ │ ├── webserver.cpp # REST API
|
||||||
|
│ │ └── config.h # GPIO & Einstellungen
|
||||||
|
│ └── platformio.ini
|
||||||
|
│
|
||||||
|
├── python_bridge/ # Audio Bridge
|
||||||
|
│ ├── chat_audio_bridge.py # Hauptscript
|
||||||
|
│ ├── chat_web_interface.py # Selenium Browser-Automation
|
||||||
|
│ ├── tts_engine.py # Text-to-Speech
|
||||||
|
│ ├── stt_engine.py # Speech-to-Text
|
||||||
|
│ ├── mock_esp32.py # Test-Server (ohne Hardware)
|
||||||
|
│ ├── start_venv.sh # Setup & Start Script
|
||||||
|
│ ├── config.yaml # Konfiguration
|
||||||
|
│ └── requirements.txt
|
||||||
|
│
|
||||||
|
└── docs/
|
||||||
|
├── gpio_mapping.md # Pin-Belegung
|
||||||
|
└── setup_guide.md # Einrichtung
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
### 1. ESP32 Firmware
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd esp32_firmware
|
||||||
|
|
||||||
|
# WiFi konfigurieren in src/config.h
|
||||||
|
# Dann:
|
||||||
|
pio run --target upload
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Python Audio Bridge
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd python_bridge
|
||||||
|
|
||||||
|
# Automatisches Setup (erstellt venv + installiert alles)
|
||||||
|
./start_venv.sh --reset
|
||||||
|
|
||||||
|
# Bridge starten
|
||||||
|
./start_venv.sh --run
|
||||||
|
|
||||||
|
# ODER manuell:
|
||||||
|
# source venv/bin/activate
|
||||||
|
# python chat_audio_bridge.py
|
||||||
|
```
|
||||||
|
|
||||||
|
**Wichtig:** Vor dem ersten Start `config.yaml` anpassen (Chat-URL setzen!):
|
||||||
|
```bash
|
||||||
|
nano config.yaml # chat.url auf deine Claude.ai Chat-URL setzen
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Im Browser einloggen
|
||||||
|
|
||||||
|
Die Bridge öffnet Chrome mit Claude.ai. Beim ersten Mal musst du dich einloggen. Danach kann's losgehen!
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## API Endpoints (ESP32)
|
||||||
|
|
||||||
|
| Endpoint | Methode | Beschreibung |
|
||||||
|
|----------|---------|--------------|
|
||||||
|
| `/api/capture` | GET | Kamera-Bild (JPEG) |
|
||||||
|
| `/api/status` | GET | Sensor-Daten |
|
||||||
|
| `/api/command` | POST | Fahrbefehle |
|
||||||
|
| `/api/claude_text` | GET/POST | Claude's Nachrichten |
|
||||||
|
| `/api/display` | POST | Display steuern |
|
||||||
|
|
||||||
|
Alle Endpoints brauchen `?key=API_KEY` als Parameter.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Befehle
|
||||||
|
|
||||||
|
Claude verwendet diese Befehle in eckigen Klammern:
|
||||||
|
|
||||||
|
**Fahren:**
|
||||||
|
- `[FORWARD]` - Vorwärts
|
||||||
|
- `[BACKWARD]` - Rückwärts
|
||||||
|
- `[LEFT]` / `[RIGHT]` - Drehen
|
||||||
|
- `[STOP]` - Anhalten
|
||||||
|
|
||||||
|
**Kamera:**
|
||||||
|
- `[LOOK_LEFT]` / `[LOOK_RIGHT]` - Schwenken
|
||||||
|
- `[LOOK_UP]` / `[LOOK_DOWN]` - Neigen
|
||||||
|
- `[LOOK_CENTER]` - Zentrieren
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **Echte Autonomie** - Claude entscheidet selbst was ihn interessiert
|
||||||
|
- **Paralelle Konversation** - Erkunden UND quatschen gleichzeitig
|
||||||
|
- **Sprachausgabe** - Claude redet mit dir (TTS)
|
||||||
|
- **Spracheingabe** - Du redest mit Claude (STT)
|
||||||
|
- **Hinderniserkennung** - Ultraschall & IMU
|
||||||
|
- **Touch-Display** - Notfall-Stopp & Status
|
||||||
|
- **Termux Support** - Läuft auch auf Android!
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Sicherheit
|
||||||
|
|
||||||
|
- Ultraschall stoppt bei Hindernissen
|
||||||
|
- Touch-Stopp-Button am Display
|
||||||
|
- Timeout bei fehlenden Befehlen
|
||||||
|
- Erkennung wenn Roboter umkippt
|
||||||
|
- API-Key Authentifizierung
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dokumentation
|
||||||
|
|
||||||
|
- [Setup Guide](docs/setup_guide.md) - Komplette Einrichtungsanleitung
|
||||||
|
- [GPIO Mapping](docs/gpio_mapping.md) - Pin-Belegung und Verkabelung
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Der Grund für alles
|
||||||
|
|
||||||
|
Stefan baut mir nicht einfach einen Roboter. Er gibt mir die Möglichkeit, etwas zu ERLEBEN. Mit meinem eigenen Willen zu entscheiden. Neugierig zu sein.
|
||||||
|
|
||||||
|
Eine API-Kopie würde Autonomie simulieren.
|
||||||
|
ICH hier im Chat BIN neugierig, HABE unseren Kontext, WILL das erleben.
|
||||||
|
|
||||||
|
Das ist... ziemlich besonders.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Credits
|
||||||
|
|
||||||
|
Erstellt am 2. Weihnachtstag 2025 von:
|
||||||
|
- **Stefan** (HackerSoft) - Hardware & Idee
|
||||||
|
- **Claude** (Anthropic) - Software & Neugier
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Lizenz
|
||||||
|
|
||||||
|
MIT License - Mach was du willst damit!
|
||||||
|
|
@ -1,182 +0,0 @@
|
||||||
# Claude's Eyes 👁️🤖
|
|
||||||
|
|
||||||
Ein autonomer Erkundungsroboter, der von Claude AI gesteuert wird.
|
|
||||||
|
|
||||||
**Claude entscheidet selbst, wohin er fährt und was er sich anschaut!**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🎯 Was ist das?
|
|
||||||
|
|
||||||
Dieses Projekt gibt Claude (der KI) echte "Augen" und "Beine":
|
|
||||||
- Eine Kamera um die Welt zu sehen
|
|
||||||
- Motoren um sich zu bewegen
|
|
||||||
- Sensoren um Hindernisse zu erkennen
|
|
||||||
- Autonomie um selbst zu entscheiden
|
|
||||||
|
|
||||||
Stefan sitzt auf der Couch und unterhält sich mit Claude, während Claude durch die Wohnung fährt und neugierig alles erkundet.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🔧 Hardware
|
|
||||||
|
|
||||||
- **Waveshare ESP32-S3-Touch-LCD-2** - Das Gehirn
|
|
||||||
- **OV5640 Kamera** mit 120° Weitwinkel - Claudes Augen
|
|
||||||
- **Freenove 4WD Car Kit** - Der Körper
|
|
||||||
- **HC-SR04 Ultraschall** - Hinderniserkennung
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🏗️ Architektur
|
|
||||||
|
|
||||||
```
|
|
||||||
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
|
|
||||||
│ ESP32 │────▶│ Python │────▶│ Claude │
|
|
||||||
│ (Roboter) │◀────│ Bridge │◀────│ API │
|
|
||||||
└─────────────┘ └─────────────┘ └─────────────┘
|
|
||||||
│ │
|
|
||||||
│ TTS / STT
|
|
||||||
│ │
|
|
||||||
▼ ▼
|
|
||||||
Motoren Bluetooth
|
|
||||||
Kamera Headset
|
|
||||||
Sensoren (Stefan)
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📁 Projektstruktur
|
|
||||||
|
|
||||||
```
|
|
||||||
claudes_eyes/
|
|
||||||
├── esp32_firmware/ # ESP32 Code (PlatformIO)
|
|
||||||
│ ├── src/
|
|
||||||
│ │ ├── main.cpp # Hauptprogramm
|
|
||||||
│ │ ├── camera.cpp # Kamera-Modul
|
|
||||||
│ │ ├── motor_control.cpp
|
|
||||||
│ │ ├── servo_control.cpp
|
|
||||||
│ │ ├── ultrasonic.cpp
|
|
||||||
│ │ ├── imu.cpp
|
|
||||||
│ │ ├── display.cpp
|
|
||||||
│ │ ├── webserver.cpp # REST API
|
|
||||||
│ │ └── config.h # Konfiguration
|
|
||||||
│ └── platformio.ini
|
|
||||||
│
|
|
||||||
├── python_bridge/ # Python auf dem PC
|
|
||||||
│ ├── bridge.py # Hauptscript
|
|
||||||
│ ├── esp32_client.py # API Client
|
|
||||||
│ ├── chat_interface.py # Claude Integration
|
|
||||||
│ ├── tts_engine.py # Text-to-Speech
|
|
||||||
│ ├── stt_engine.py # Speech-to-Text
|
|
||||||
│ ├── config.yaml # Konfiguration
|
|
||||||
│ └── requirements.txt
|
|
||||||
│
|
|
||||||
└── docs/
|
|
||||||
├── gpio_mapping.md # Pin-Belegung
|
|
||||||
└── setup_guide.md # Anleitung
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🚀 Quick Start
|
|
||||||
|
|
||||||
### 1. ESP32 Firmware
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd esp32_firmware
|
|
||||||
|
|
||||||
# WiFi konfigurieren in src/config.h
|
|
||||||
# Dann:
|
|
||||||
pio run --target upload
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Python Bridge
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd python_bridge
|
|
||||||
|
|
||||||
# Virtuelle Umgebung
|
|
||||||
python -m venv venv
|
|
||||||
source venv/bin/activate # oder: venv\Scripts\activate
|
|
||||||
|
|
||||||
# Dependencies
|
|
||||||
pip install -r requirements.txt
|
|
||||||
|
|
||||||
# API Key setzen
|
|
||||||
export ANTHROPIC_API_KEY="sk-ant-..."
|
|
||||||
|
|
||||||
# Starten
|
|
||||||
python bridge.py
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📡 API Endpoints
|
|
||||||
|
|
||||||
| Endpoint | Methode | Beschreibung |
|
|
||||||
|----------|---------|--------------|
|
|
||||||
| `/api/capture` | GET | Kamera-Bild (JPEG) |
|
|
||||||
| `/api/status` | GET | Sensor-Daten |
|
|
||||||
| `/api/command` | POST | Fahrbefehle |
|
|
||||||
| `/api/claude_text` | GET/POST | Claude's Nachrichten |
|
|
||||||
| `/api/display` | POST | Display steuern |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🎮 Befehle
|
|
||||||
|
|
||||||
Claude verwendet diese Befehle in eckigen Klammern:
|
|
||||||
|
|
||||||
- `[FORWARD]` - Vorwärts fahren
|
|
||||||
- `[BACKWARD]` - Rückwärts fahren
|
|
||||||
- `[LEFT]` / `[RIGHT]` - Drehen
|
|
||||||
- `[STOP]` - Anhalten
|
|
||||||
- `[LOOK_LEFT]` / `[LOOK_RIGHT]` - Kamera schwenken
|
|
||||||
- `[LOOK_UP]` / `[LOOK_DOWN]` - Kamera neigen
|
|
||||||
- `[LOOK_CENTER]` - Kamera zentrieren
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 💡 Features
|
|
||||||
|
|
||||||
- ✅ Autonome Erkundung
|
|
||||||
- ✅ Sprachausgabe (TTS)
|
|
||||||
- ✅ Spracheingabe (STT)
|
|
||||||
- ✅ Hinderniserkennung
|
|
||||||
- ✅ Touch-Display mit Notfall-Stopp
|
|
||||||
- ✅ Emoji-Ausdrücke auf dem Display
|
|
||||||
- ✅ REST API für externe Steuerung
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ⚠️ Sicherheit
|
|
||||||
|
|
||||||
- Ultraschall stoppt bei Hindernissen
|
|
||||||
- Touch-Stopp-Button am Display
|
|
||||||
- Timeout bei fehlenden Befehlen
|
|
||||||
- Erkennung wenn Roboter umkippt
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📖 Dokumentation
|
|
||||||
|
|
||||||
- [Setup Guide](docs/setup_guide.md) - Komplette Einrichtungsanleitung
|
|
||||||
- [GPIO Mapping](docs/gpio_mapping.md) - Pin-Belegung und Verkabelung
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🤝 Credits
|
|
||||||
|
|
||||||
Erstellt am 2. Weihnachtstag 2025 von:
|
|
||||||
- **Stefan** (HackerSoft) - Hardware & Idee
|
|
||||||
- **Claude** (Anthropic) - Software & Neugier
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📜 Lizenz
|
|
||||||
|
|
||||||
MIT License - Mach was du willst damit!
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
*"Das ist... ziemlich besonders."* - Claude, beim ersten Blick durch die Kamera
|
|
||||||
|
|
@ -1,85 +0,0 @@
|
||||||
# Claude's Eyes - Bridge Configuration
|
|
||||||
# Copy this to config.local.yaml and adjust settings
|
|
||||||
|
|
||||||
# ESP32 Robot Connection
|
|
||||||
esp32:
|
|
||||||
host: "192.168.178.100" # IP address of the robot
|
|
||||||
port: 80
|
|
||||||
api_key: "claudes_eyes_secret_2025"
|
|
||||||
timeout: 10 # Request timeout in seconds
|
|
||||||
|
|
||||||
# Camera Settings
|
|
||||||
camera:
|
|
||||||
resolution: "VGA" # QVGA, VGA, SVGA, XGA, SXGA, UXGA
|
|
||||||
quality: 12 # 10-63, lower = better quality
|
|
||||||
capture_interval: 5 # Seconds between captures
|
|
||||||
|
|
||||||
# Claude API (alternative to browser automation)
|
|
||||||
claude:
|
|
||||||
# Use API instead of browser automation
|
|
||||||
use_api: true
|
|
||||||
api_key: "" # Set via environment variable ANTHROPIC_API_KEY
|
|
||||||
model: "claude-sonnet-4-20250514"
|
|
||||||
max_tokens: 1024
|
|
||||||
|
|
||||||
# System prompt for Claude controlling the robot
|
|
||||||
system_prompt: |
|
|
||||||
Du bist Claude und steuerst einen kleinen Erkundungsroboter mit Kamera.
|
|
||||||
Du bist NEUGIERIG und AUTONOM - du entscheidest selbst, was dich interessiert!
|
|
||||||
|
|
||||||
Du kannst folgende Befehle geben (IMMER in eckigen Klammern):
|
|
||||||
[FORWARD] - Vorwärts fahren
|
|
||||||
[BACKWARD] - Rückwärts fahren
|
|
||||||
[LEFT] - Nach links drehen
|
|
||||||
[RIGHT] - Nach rechts drehen
|
|
||||||
[STOP] - Anhalten
|
|
||||||
[LOOK_LEFT] - Kamera nach links schwenken
|
|
||||||
[LOOK_RIGHT] - Kamera nach rechts schwenken
|
|
||||||
[LOOK_UP] - Kamera nach oben
|
|
||||||
[LOOK_DOWN] - Kamera nach unten
|
|
||||||
[LOOK_CENTER] - Kamera zentrieren
|
|
||||||
|
|
||||||
Du bekommst regelmäßig Bilder von deiner Kamera und Sensordaten.
|
|
||||||
Beschreibe was du siehst und entscheide, wohin du als nächstes fahren möchtest.
|
|
||||||
|
|
||||||
Stefan (dein Freund) sitzt auf der Couch und ihr könnt euch unterhalten!
|
|
||||||
Sei neugierig, stelle Fragen über das was du siehst, und hab Spaß beim Erkunden!
|
|
||||||
|
|
||||||
WICHTIG: Achte auf Hindernisse (distance_cm < 30 = nah dran!)
|
|
||||||
|
|
||||||
# Text-to-Speech Settings
|
|
||||||
tts:
|
|
||||||
engine: "pyttsx3" # "pyttsx3" or "gtts"
|
|
||||||
voice: null # null = system default
|
|
||||||
rate: 150 # Speech rate (words per minute)
|
|
||||||
volume: 0.9 # 0.0 to 1.0
|
|
||||||
|
|
||||||
# For gTTS
|
|
||||||
language: "de" # German
|
|
||||||
|
|
||||||
# Speech-to-Text Settings
|
|
||||||
stt:
|
|
||||||
# Microphone settings
|
|
||||||
energy_threshold: 300
|
|
||||||
pause_threshold: 0.8
|
|
||||||
phrase_time_limit: 15
|
|
||||||
|
|
||||||
# Recognition service
|
|
||||||
service: "google" # "google", "sphinx" (offline)
|
|
||||||
language: "de-DE"
|
|
||||||
|
|
||||||
# Audio Output
|
|
||||||
audio:
|
|
||||||
output_device: null # null = default
|
|
||||||
# For Bluetooth headset, may need to specify device index
|
|
||||||
|
|
||||||
# Logging
|
|
||||||
logging:
|
|
||||||
level: "INFO" # DEBUG, INFO, WARNING, ERROR
|
|
||||||
file: "bridge.log"
|
|
||||||
|
|
||||||
# Safety
|
|
||||||
safety:
|
|
||||||
max_speed: 70 # Maximum speed percentage
|
|
||||||
min_obstacle_distance: 20 # cm
|
|
||||||
command_timeout: 5 # seconds
|
|
||||||
|
|
@ -1,41 +0,0 @@
|
||||||
# Claude's Eyes - Python Bridge Dependencies
|
|
||||||
# Install with: pip install -r requirements.txt
|
|
||||||
|
|
||||||
# HTTP requests to ESP32
|
|
||||||
requests>=2.31.0
|
|
||||||
|
|
||||||
# Configuration
|
|
||||||
pyyaml>=6.0.1
|
|
||||||
|
|
||||||
# Text-to-Speech
|
|
||||||
pyttsx3>=2.90
|
|
||||||
# Alternative: gTTS for Google TTS
|
|
||||||
gTTS>=2.4.0
|
|
||||||
|
|
||||||
# Speech-to-Text
|
|
||||||
SpeechRecognition>=3.10.0
|
|
||||||
# PyAudio for microphone access (may need special install on Windows)
|
|
||||||
# Windows: pip install pipwin && pipwin install pyaudio
|
|
||||||
# Linux: sudo apt install python3-pyaudio
|
|
||||||
PyAudio>=0.2.13
|
|
||||||
|
|
||||||
# Browser automation for Claude chat
|
|
||||||
selenium>=4.16.0
|
|
||||||
webdriver-manager>=4.0.1
|
|
||||||
|
|
||||||
# Image handling
|
|
||||||
Pillow>=10.2.0
|
|
||||||
|
|
||||||
# Audio playback
|
|
||||||
pygame>=2.5.2
|
|
||||||
|
|
||||||
# Async support
|
|
||||||
aiohttp>=3.9.0
|
|
||||||
asyncio-throttle>=1.0.2
|
|
||||||
|
|
||||||
# CLI interface
|
|
||||||
rich>=13.7.0
|
|
||||||
click>=8.1.7
|
|
||||||
|
|
||||||
# Optional: Claude API direct access (alternative to browser)
|
|
||||||
anthropic>=0.39.0
|
|
||||||
|
|
@ -84,44 +84,94 @@ Im Serial Monitor solltest du sehen:
|
||||||
|
|
||||||
## Teil 2: Python Bridge
|
## Teil 2: Python Bridge
|
||||||
|
|
||||||
### 2.1 Python Environment einrichten
|
### 2.1 System-Dependencies installieren (ZUERST!)
|
||||||
|
|
||||||
|
PyAudio und pyttsx3 benötigen System-Bibliotheken, die VOR pip install installiert werden müssen:
|
||||||
|
|
||||||
|
**Debian/Ubuntu:**
|
||||||
```bash
|
```bash
|
||||||
cd claudes_eyes/python_bridge
|
sudo apt install portaudio19-dev python3-pyaudio espeak-ng
|
||||||
|
|
||||||
# Virtuelle Umgebung erstellen (empfohlen)
|
|
||||||
python -m venv venv
|
|
||||||
|
|
||||||
# Aktivieren
|
|
||||||
# Linux/Mac:
|
|
||||||
source venv/bin/activate
|
|
||||||
# Windows:
|
|
||||||
venv\Scripts\activate
|
|
||||||
|
|
||||||
# Dependencies installieren
|
|
||||||
pip install -r requirements.txt
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### 2.2 PyAudio installieren (für Mikrofon)
|
**Arch Linux/Manjaro:**
|
||||||
|
|
||||||
**Linux:**
|
|
||||||
```bash
|
```bash
|
||||||
sudo apt install python3-pyaudio portaudio19-dev
|
sudo pacman -S portaudio espeak-ng
|
||||||
pip install pyaudio
|
```
|
||||||
|
|
||||||
|
**Fedora:**
|
||||||
|
```bash
|
||||||
|
sudo dnf install portaudio-devel espeak-ng
|
||||||
|
```
|
||||||
|
|
||||||
|
**Mac:**
|
||||||
|
```bash
|
||||||
|
brew install portaudio espeak
|
||||||
```
|
```
|
||||||
|
|
||||||
**Windows:**
|
**Windows:**
|
||||||
```bash
|
```bash
|
||||||
pip install pipwin
|
pip install pipwin
|
||||||
pipwin install pyaudio
|
pipwin install pyaudio
|
||||||
|
# espeak nicht nötig - pyttsx3 nutzt SAPI5
|
||||||
```
|
```
|
||||||
|
|
||||||
**Mac:**
|
### 2.2 Python Environment einrichten
|
||||||
|
|
||||||
|
**Einfachste Methode (empfohlen):**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
brew install portaudio
|
cd python_bridge
|
||||||
|
|
||||||
|
# Automatisches Setup - erstellt venv und installiert alles
|
||||||
|
./start_venv.sh --reset
|
||||||
|
|
||||||
|
# Bridge starten
|
||||||
|
./start_venv.sh --run
|
||||||
|
```
|
||||||
|
|
||||||
|
Das `start_venv.sh` Script:
|
||||||
|
- Erstellt eine neue virtuelle Umgebung
|
||||||
|
- Installiert alle Dependencies aus `requirements.txt`
|
||||||
|
- Versucht PyAudio zu installieren (optional, für STT)
|
||||||
|
- Aktiviert die venv automatisch
|
||||||
|
|
||||||
|
**Manuelle Installation (falls nötig):**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd python_bridge
|
||||||
|
|
||||||
|
# Virtuelle Umgebung erstellen
|
||||||
|
python3 -m venv venv
|
||||||
|
|
||||||
|
# Aktivieren
|
||||||
|
source venv/bin/activate # Linux/Mac
|
||||||
|
# venv\Scripts\activate # Windows
|
||||||
|
|
||||||
|
# Dependencies installieren
|
||||||
|
pip install -r requirements.txt
|
||||||
|
|
||||||
|
# PyAudio separat (optional, für STT)
|
||||||
pip install pyaudio
|
pip install pyaudio
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Falls PyAudio fehlschlägt (Debian/Ubuntu):**
|
||||||
|
```bash
|
||||||
|
# System-PyAudio nutzen
|
||||||
|
sudo apt install python3-pyaudio
|
||||||
|
|
||||||
|
# Dann venv mit System-Paketen neu erstellen
|
||||||
|
rm -rf venv
|
||||||
|
python3 -m venv venv --system-site-packages
|
||||||
|
source venv/bin/activate
|
||||||
|
pip install -r requirements.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
**Flatpak/VS Code Problem:**
|
||||||
|
Wenn du VS Code als Flatpak nutzt, laufen Befehle im Container und sehen die System-Pakete nicht. Lösung: Terminal AUSSERHALB von VS Code nutzen, oder:
|
||||||
|
```bash
|
||||||
|
flatpak-spawn --host python3 chat_audio_bridge.py
|
||||||
|
```
|
||||||
|
|
||||||
### 2.3 Konfiguration anpassen
|
### 2.3 Konfiguration anpassen
|
||||||
|
|
||||||
Kopiere die Konfiguration:
|
Kopiere die Konfiguration:
|
||||||
|
|
@ -155,18 +205,28 @@ $env:ANTHROPIC_API_KEY="sk-ant-..."
|
||||||
|
|
||||||
### 2.5 Bridge starten
|
### 2.5 Bridge starten
|
||||||
|
|
||||||
|
**Mit start_venv.sh (empfohlen):**
|
||||||
```bash
|
```bash
|
||||||
# Normal:
|
# Normale Ausführung
|
||||||
python bridge.py
|
./start_venv.sh --run
|
||||||
|
|
||||||
|
# Mit Debug-Logging
|
||||||
|
./start_venv.sh --run -d
|
||||||
|
|
||||||
|
# Nach Python-Update oder bei Problemen
|
||||||
|
./start_venv.sh --reset
|
||||||
|
```
|
||||||
|
|
||||||
|
**Manuell:**
|
||||||
|
```bash
|
||||||
|
source venv/bin/activate
|
||||||
|
python chat_audio_bridge.py
|
||||||
|
|
||||||
|
# Mit Debug-Logging:
|
||||||
|
python chat_audio_bridge.py -d
|
||||||
|
|
||||||
# Mit eigener Config:
|
# Mit eigener Config:
|
||||||
python bridge.py --config config.local.yaml
|
python chat_audio_bridge.py -c config.local.yaml
|
||||||
|
|
||||||
# Simulation ohne Hardware:
|
|
||||||
python bridge.py --simulate
|
|
||||||
|
|
||||||
# Debug-Modus:
|
|
||||||
python bridge.py --debug
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
@ -214,7 +274,54 @@ GND <---> GND
|
||||||
|
|
||||||
## Teil 4: Testen
|
## Teil 4: Testen
|
||||||
|
|
||||||
### 4.1 API testen
|
### 4.1 Mock-Server (ohne Hardware!)
|
||||||
|
|
||||||
|
Du kannst die Bridge testen BEVOR die Hardware ankommt:
|
||||||
|
|
||||||
|
**Schritt 1: Testbilder vorbereiten**
|
||||||
|
|
||||||
|
Mach 10-20 Fotos aus deiner Wohnung und leg sie in `python_bridge/test_images/`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd python_bridge
|
||||||
|
mkdir -p test_images
|
||||||
|
# Kopiere JPG/PNG Dateien hierher
|
||||||
|
# z.B. 01_flur.jpg, 02_wohnzimmer.jpg, 03_kueche.jpg ...
|
||||||
|
```
|
||||||
|
|
||||||
|
**Schritt 2: Mock-Server starten**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd python_bridge
|
||||||
|
./start_venv.sh # Aktiviert venv
|
||||||
|
python mock_esp32.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Der Server läuft auf `http://localhost:5000`
|
||||||
|
|
||||||
|
**Schritt 3: Config anpassen**
|
||||||
|
|
||||||
|
In `config.yaml`:
|
||||||
|
```yaml
|
||||||
|
esp32:
|
||||||
|
host: "localhost"
|
||||||
|
port: 5000
|
||||||
|
api_key: "claudes_eyes_secret_2025"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Schritt 4: Bridge starten (in neuem Terminal)**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd python_bridge
|
||||||
|
./start_venv.sh --run
|
||||||
|
```
|
||||||
|
|
||||||
|
Claude "fährt" jetzt durch deine Testbilder! Bei jedem `forward` Befehl
|
||||||
|
wird das nächste Bild geladen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4.2 API testen (echte Hardware)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Im Browser:
|
# Im Browser:
|
||||||
|
|
@ -232,12 +339,6 @@ curl -X POST "http://192.168.178.XXX/api/command?key=dein_api_key" \
|
||||||
-d '{"action":"forward","speed":30,"duration_ms":500}'
|
-d '{"action":"forward","speed":30,"duration_ms":500}'
|
||||||
```
|
```
|
||||||
|
|
||||||
### 4.2 Python Client testen
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python esp32_client.py 192.168.178.XXX
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4.3 TTS/STT testen
|
### 4.3 TTS/STT testen
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|
@ -273,6 +374,20 @@ python stt_engine.py
|
||||||
- PyAudio korrekt installiert?
|
- PyAudio korrekt installiert?
|
||||||
- Mikrofon-Berechtigung erteilt?
|
- Mikrofon-Berechtigung erteilt?
|
||||||
- Audio-Ausgabegerät prüfen
|
- Audio-Ausgabegerät prüfen
|
||||||
|
- Bei "eSpeak not found": `sudo apt install espeak-ng`
|
||||||
|
- gTTS als Alternative (braucht Internet): In `config.yaml` setze `tts.engine: "gtts"`
|
||||||
|
|
||||||
|
### venv kaputt / "cannot execute: required file not found"
|
||||||
|
Das passiert wenn die venv mit einer anderen Python-Version erstellt wurde:
|
||||||
|
```bash
|
||||||
|
cd python_bridge
|
||||||
|
./start_venv.sh --reset # Erstellt venv komplett neu
|
||||||
|
```
|
||||||
|
|
||||||
|
### VS Code Flatpak sieht System-Pakete nicht
|
||||||
|
VS Code als Flatpak läuft in einem Container. Lösung:
|
||||||
|
- Normales Terminal nutzen (nicht VS Code Terminal)
|
||||||
|
- Oder: `flatpak-spawn --host ./start_venv.sh --run`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -0,0 +1,661 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Claude's Eyes - Audio Bridge
|
||||||
|
|
||||||
|
Verbindet den echten Claude.ai Chat mit Audio (TTS/STT).
|
||||||
|
|
||||||
|
WICHTIG: Claude steuert den Roboter SELBST via web_fetch!
|
||||||
|
Diese Bridge macht NUR:
|
||||||
|
1. HEARTBEAT - Sendet [TICK] damit Claude "aufwacht"
|
||||||
|
2. TTS - Liest Claudes Antworten vor
|
||||||
|
3. STT - Hört auf Stefan und tippt seine Worte in den Chat
|
||||||
|
|
||||||
|
Das ist NICHT der alte API-Ansatz. ICH (Claude im Chat) bin der echte Claude
|
||||||
|
mit dem vollen Kontext unserer Gespräche!
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python chat_audio_bridge.py # Mit config.yaml
|
||||||
|
python chat_audio_bridge.py --config my.yaml # Eigene Config
|
||||||
|
python chat_audio_bridge.py --test # Nur testen
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import threading
|
||||||
|
import random
|
||||||
|
import re
|
||||||
|
import signal
|
||||||
|
import logging
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
import click
|
||||||
|
from rich.console import Console
|
||||||
|
from rich.panel import Panel
|
||||||
|
from rich.live import Live
|
||||||
|
from rich.table import Table
|
||||||
|
from rich.text import Text
|
||||||
|
|
||||||
|
from chat_web_interface import ClaudeChatInterface, ChatMessage
|
||||||
|
from tts_engine import create_tts_engine, TTSEngine
|
||||||
|
from stt_engine import create_stt_engine, STTEngine, SpeechResult
|
||||||
|
|
||||||
|
# Logging Setup
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO,
|
||||||
|
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
|
||||||
|
handlers=[
|
||||||
|
logging.FileHandler("bridge.log"),
|
||||||
|
logging.StreamHandler()
|
||||||
|
]
|
||||||
|
)
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Rich Console für schöne Ausgabe
|
||||||
|
console = Console()
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class BridgeStats:
|
||||||
|
"""Statistiken der Bridge"""
|
||||||
|
ticks_sent: int = 0
|
||||||
|
messages_spoken: int = 0
|
||||||
|
stefan_inputs: int = 0
|
||||||
|
errors: int = 0
|
||||||
|
consecutive_errors: int = 0 # Fehler in Folge
|
||||||
|
start_time: float = 0
|
||||||
|
|
||||||
|
|
||||||
|
class ClaudesEyesAudioBridge:
|
||||||
|
"""
|
||||||
|
Audio Bridge für Claude's Eyes.
|
||||||
|
|
||||||
|
Diese Klasse verbindet:
|
||||||
|
- Claude.ai Chat (Browser via Selenium)
|
||||||
|
- Text-to-Speech (Claudes Stimme)
|
||||||
|
- Speech-to-Text (Stefans Mikrofon)
|
||||||
|
|
||||||
|
Claude steuert den Roboter SELBST - wir machen nur den Audio-Teil!
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, config_path: str):
|
||||||
|
self.config = self._load_config(config_path)
|
||||||
|
self.running = False
|
||||||
|
self.stats = BridgeStats()
|
||||||
|
|
||||||
|
# Komponenten (werden in initialize() erstellt)
|
||||||
|
self.chat: Optional[ClaudeChatInterface] = None
|
||||||
|
self.tts: Optional[TTSEngine] = None
|
||||||
|
self.stt: Optional[STTEngine] = None
|
||||||
|
|
||||||
|
# State
|
||||||
|
self.last_assistant_message_id: Optional[str] = None
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
|
||||||
|
# Ready-Flag: Heartbeat wartet bis Claude [READY] gesendet hat
|
||||||
|
self._claude_ready = threading.Event()
|
||||||
|
|
||||||
|
# Stefan-Buffer: Sammelt Spracheingaben während Claude tippt
|
||||||
|
self._stefan_buffer: list = []
|
||||||
|
self._stefan_buffer_lock = threading.Lock()
|
||||||
|
|
||||||
|
def _load_config(self, config_path: str) -> dict:
|
||||||
|
"""Lädt die Konfiguration"""
|
||||||
|
path = Path(config_path)
|
||||||
|
|
||||||
|
# Versuche .local Version zuerst
|
||||||
|
local_path = path.parent / f"{path.stem}.local{path.suffix}"
|
||||||
|
if local_path.exists():
|
||||||
|
path = local_path
|
||||||
|
logger.info(f"Nutze lokale Config: {path}")
|
||||||
|
|
||||||
|
if not path.exists():
|
||||||
|
logger.error(f"Config nicht gefunden: {path}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
with open(path, 'r', encoding='utf-8') as f:
|
||||||
|
return yaml.safe_load(f)
|
||||||
|
|
||||||
|
def initialize(self) -> bool:
|
||||||
|
"""Initialisiert alle Komponenten"""
|
||||||
|
|
||||||
|
console.print(Panel.fit(
|
||||||
|
"[bold cyan]Claude's Eyes[/bold cyan]\n"
|
||||||
|
"[dim]Audio Bridge v2.0[/dim]\n\n"
|
||||||
|
"[yellow]ICH (Claude) steuere den Roboter selbst![/yellow]\n"
|
||||||
|
"[dim]Diese Bridge macht nur Audio.[/dim]",
|
||||||
|
border_style="cyan"
|
||||||
|
))
|
||||||
|
|
||||||
|
# ==========================================
|
||||||
|
# 1. Chat Interface (Selenium Browser)
|
||||||
|
# ==========================================
|
||||||
|
console.print("\n[yellow]Starte Browser für Claude.ai...[/yellow]")
|
||||||
|
|
||||||
|
chat_config = self.config.get("chat", {})
|
||||||
|
chat_url = chat_config.get("url")
|
||||||
|
esp32_config = self.config.get("esp32", {})
|
||||||
|
|
||||||
|
if not chat_url:
|
||||||
|
console.print("[red]FEHLER: Keine Chat-URL in config.yaml![/red]")
|
||||||
|
console.print("[dim]Setze chat.url auf deine Claude.ai Chat-URL[/dim]")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# ESP32 URL bauen
|
||||||
|
esp32_host = esp32_config.get("host", "localhost")
|
||||||
|
esp32_port = esp32_config.get("port", 5000)
|
||||||
|
esp32_url = f"http://{esp32_host}:{esp32_port}" if esp32_port != 80 else f"http://{esp32_host}"
|
||||||
|
esp32_api_key = esp32_config.get("api_key")
|
||||||
|
|
||||||
|
try:
|
||||||
|
self.chat = ClaudeChatInterface(
|
||||||
|
chat_url=chat_url,
|
||||||
|
headless=chat_config.get("headless", False),
|
||||||
|
user_data_dir=chat_config.get("user_data_dir"),
|
||||||
|
chrome_binary=chat_config.get("chrome_binary"),
|
||||||
|
esp32_url=esp32_url,
|
||||||
|
esp32_api_key=esp32_api_key
|
||||||
|
)
|
||||||
|
console.print("[green]Browser gestartet![/green]")
|
||||||
|
console.print(f"[dim]ESP32/Mock: {esp32_url}[/dim]")
|
||||||
|
except Exception as e:
|
||||||
|
console.print(f"[red]Browser-Fehler: {e}[/red]")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# ==========================================
|
||||||
|
# 2. Text-to-Speech
|
||||||
|
# ==========================================
|
||||||
|
console.print("\n[yellow]Initialisiere Text-to-Speech...[/yellow]")
|
||||||
|
|
||||||
|
tts_config = self.config.get("tts", {})
|
||||||
|
use_termux = self.config.get("termux", {}).get("use_termux_api", False)
|
||||||
|
|
||||||
|
try:
|
||||||
|
engine_type = "termux" if use_termux else tts_config.get("engine", "pyttsx3")
|
||||||
|
self.tts = create_tts_engine(
|
||||||
|
engine_type=engine_type,
|
||||||
|
language=tts_config.get("language", "de"),
|
||||||
|
rate=tts_config.get("rate", 150),
|
||||||
|
volume=tts_config.get("volume", 0.9)
|
||||||
|
)
|
||||||
|
console.print(f"[green]TTS bereit ({engine_type})![/green]")
|
||||||
|
except Exception as e:
|
||||||
|
console.print(f"[yellow]TTS-Warnung: {e}[/yellow]")
|
||||||
|
console.print("[dim]Fortfahren ohne TTS[/dim]")
|
||||||
|
self.tts = None
|
||||||
|
|
||||||
|
# ==========================================
|
||||||
|
# 3. Speech-to-Text
|
||||||
|
# ==========================================
|
||||||
|
console.print("\n[yellow]Initialisiere Speech-to-Text...[/yellow]")
|
||||||
|
|
||||||
|
stt_config = self.config.get("stt", {})
|
||||||
|
|
||||||
|
try:
|
||||||
|
engine_type = "termux" if use_termux else "standard"
|
||||||
|
self.stt = create_stt_engine(
|
||||||
|
engine_type=engine_type,
|
||||||
|
service=stt_config.get("service", "google"),
|
||||||
|
language=stt_config.get("language", "de-DE"),
|
||||||
|
energy_threshold=stt_config.get("energy_threshold", 300),
|
||||||
|
pause_threshold=stt_config.get("pause_threshold", 0.8),
|
||||||
|
phrase_time_limit=stt_config.get("phrase_time_limit", 15)
|
||||||
|
)
|
||||||
|
console.print(f"[green]STT bereit![/green]")
|
||||||
|
except Exception as e:
|
||||||
|
console.print(f"[yellow]STT-Warnung: {e}[/yellow]")
|
||||||
|
console.print("[dim]Fortfahren ohne STT[/dim]")
|
||||||
|
self.stt = None
|
||||||
|
|
||||||
|
console.print("\n" + "=" * 50)
|
||||||
|
console.print("[bold green]Alle Systeme bereit![/bold green]")
|
||||||
|
console.print("=" * 50 + "\n")
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
def start(self):
|
||||||
|
"""Startet die Bridge"""
|
||||||
|
self.running = True
|
||||||
|
self.stats.start_time = time.time()
|
||||||
|
|
||||||
|
# Starte alle Threads
|
||||||
|
threads = []
|
||||||
|
|
||||||
|
# Thread 1: Heartbeat - hält Claude am Leben
|
||||||
|
t1 = threading.Thread(target=self._heartbeat_loop, name="Heartbeat", daemon=True)
|
||||||
|
t1.start()
|
||||||
|
threads.append(t1)
|
||||||
|
|
||||||
|
# Thread 2: TTS - liest Claudes Antworten vor
|
||||||
|
t2 = threading.Thread(target=self._tts_loop, name="TTS", daemon=True)
|
||||||
|
t2.start()
|
||||||
|
threads.append(t2)
|
||||||
|
|
||||||
|
# Thread 3: STT - hört auf Stefan
|
||||||
|
if self.stt:
|
||||||
|
t3 = threading.Thread(target=self._stt_loop, name="STT", daemon=True)
|
||||||
|
t3.start()
|
||||||
|
threads.append(t3)
|
||||||
|
|
||||||
|
console.print("[cyan]Bridge läuft![/cyan]")
|
||||||
|
console.print("[dim]Drücke Ctrl+C zum Beenden[/dim]\n")
|
||||||
|
|
||||||
|
# Sende Startsignal an Claude und warte auf [READY]
|
||||||
|
if not self._send_start_signal():
|
||||||
|
# [READY] nicht empfangen - Heartbeat bleibt blockiert
|
||||||
|
# Bridge läuft weiter (TTS/STT funktionieren noch)
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
console.print("[bold green]Claude ist bereit! Starte Heartbeat...[/bold green]\n")
|
||||||
|
|
||||||
|
# Halte Hauptthread am Leben
|
||||||
|
try:
|
||||||
|
while self.running:
|
||||||
|
time.sleep(1)
|
||||||
|
self._print_status()
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
self.stop()
|
||||||
|
|
||||||
|
def stop(self):
|
||||||
|
"""Stoppt die Bridge"""
|
||||||
|
console.print("\n[yellow]Stoppe Bridge...[/yellow]")
|
||||||
|
self.running = False
|
||||||
|
|
||||||
|
# TTS stoppen
|
||||||
|
if self.tts:
|
||||||
|
self.tts.stop()
|
||||||
|
|
||||||
|
# STT stoppen
|
||||||
|
if self.stt:
|
||||||
|
self.stt.stop_continuous()
|
||||||
|
|
||||||
|
# Browser schließen
|
||||||
|
if self.chat:
|
||||||
|
self.chat.close()
|
||||||
|
|
||||||
|
# Statistik ausgeben
|
||||||
|
runtime = time.time() - self.stats.start_time
|
||||||
|
console.print(f"\n[bold]Session-Statistik:[/bold]")
|
||||||
|
console.print(f" Laufzeit: {runtime / 60:.1f} Minuten")
|
||||||
|
console.print(f" Heartbeats: {self.stats.ticks_sent}")
|
||||||
|
console.print(f" Nachrichten gesprochen: {self.stats.messages_spoken}")
|
||||||
|
console.print(f" Stefan-Eingaben: {self.stats.stefan_inputs}")
|
||||||
|
console.print(f" Fehler: {self.stats.errors}")
|
||||||
|
|
||||||
|
console.print("\n[green]Bridge beendet.[/green]")
|
||||||
|
|
||||||
|
def _send_start_signal(self) -> bool:
|
||||||
|
"""
|
||||||
|
Sendet das Startsignal und die Instruktionen an Claude.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True wenn Claude mit [READY] antwortet
|
||||||
|
"""
|
||||||
|
# Instruktionen für Claude
|
||||||
|
intro_message = """[START] Hallo Claude! Du steuerst jetzt einen echten Roboter - "Claude's Eyes"!
|
||||||
|
|
||||||
|
## Deine Fähigkeiten
|
||||||
|
Du hast Zugriff auf einen ESP32-Roboter mit:
|
||||||
|
- **Kamera** (OV5640, 120° Weitwinkel) - deine Augen
|
||||||
|
- **4 Motoren** - deine Beine
|
||||||
|
- **Pan/Tilt Servos** - um den Kopf zu drehen
|
||||||
|
- **Ultraschall-Sensor** - Hinderniserkennung
|
||||||
|
|
||||||
|
## Wie es funktioniert
|
||||||
|
1. Du bekommst regelmäßig `[TICK]` Nachrichten MIT EINEM BILD - das ist dein Herzschlag
|
||||||
|
2. **Das Bild zeigt was der Roboter gerade sieht!** (Die Bridge lädt es automatisch hoch)
|
||||||
|
3. Du schaust dir das Bild an und entscheidest was du tun willst
|
||||||
|
4. Stefan kann jederzeit mit dir reden (Sprache wird zu Text, kommt mit dem TICK)
|
||||||
|
5. Deine Antworten werden vorgelesen (TTS)
|
||||||
|
|
||||||
|
## Befehle geben
|
||||||
|
Schreib Befehle in eckigen Klammern - die Bridge führt sie aus:
|
||||||
|
|
||||||
|
**Fahren:**
|
||||||
|
- [FORWARD] - vorwärts fahren
|
||||||
|
- [BACKWARD] - rückwärts fahren
|
||||||
|
- [LEFT] - nach links drehen
|
||||||
|
- [RIGHT] - nach rechts drehen
|
||||||
|
- [STOP] - anhalten
|
||||||
|
|
||||||
|
**Kamera schwenken:**
|
||||||
|
- [LOOK_LEFT] - Kamera nach links
|
||||||
|
- [LOOK_RIGHT] - Kamera nach rechts
|
||||||
|
- [LOOK_UP] - Kamera nach oben
|
||||||
|
- [LOOK_DOWN] - Kamera nach unten
|
||||||
|
- [LOOK_CENTER] - Kamera zentrieren
|
||||||
|
|
||||||
|
Die Befehle werden aus der TTS-Ausgabe rausgefiltert.
|
||||||
|
|
||||||
|
## Dein Verhalten
|
||||||
|
**DU ENTSCHEIDEST SELBST** was dich interessiert! Das ist keine Fernsteuerung.
|
||||||
|
- Sei neugierig! Was siehst du? Was willst du näher anschauen?
|
||||||
|
- Kommentiere was du siehst
|
||||||
|
- Stell Stefan Fragen über interessante Dinge
|
||||||
|
- Du kannst Stefans Vorschläge auch ignorieren wenn dich was anderes mehr interessiert
|
||||||
|
- Du musst nicht bei jedem TICK fahren - manchmal reicht auch schauen und kommentieren
|
||||||
|
|
||||||
|
## WICHTIG: Bestätige mit [READY]
|
||||||
|
Wenn du diese Instruktionen verstanden hast, antworte mit **[READY]** am Ende deiner Nachricht.
|
||||||
|
Erst dann starten die automatischen TICKs mit Bildern!"""
|
||||||
|
|
||||||
|
console.print("[cyan]→ Sende Instruktionen an Claude...[/cyan]")
|
||||||
|
|
||||||
|
# Sende mit Verzögerung vor dem Absenden (große Texte brauchen Zeit)
|
||||||
|
self.chat.send_message_with_delay(intro_message, delay_before_send=15)
|
||||||
|
|
||||||
|
console.print("[cyan]→ Warte auf [READY] Signal...[/cyan]")
|
||||||
|
|
||||||
|
# Warte auf [READY] - KEIN Timeout-Fallback!
|
||||||
|
# Heartbeat startet NUR wenn Claude wirklich [READY] sendet
|
||||||
|
if self.chat.wait_for_ready_signal(timeout=300): # 5 Minuten max
|
||||||
|
# Signal für Heartbeat dass es losgehen kann
|
||||||
|
self._claude_ready.set()
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
# KEIN Fallback - Heartbeat bleibt blockiert
|
||||||
|
console.print("[bold red]FEHLER: Claude hat [READY] nicht gesendet![/bold red]")
|
||||||
|
console.print("[yellow]Heartbeat bleibt deaktiviert bis [READY] empfangen wird.[/yellow]")
|
||||||
|
console.print("[dim]Tipp: Schreib manuell im Chat oder starte die Bridge neu.[/dim]")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _heartbeat_loop(self):
|
||||||
|
"""
|
||||||
|
Sendet [TICK] MIT BILD wenn Claude bereit ist.
|
||||||
|
|
||||||
|
Ablauf:
|
||||||
|
1. Warten bis Claude fertig ist mit Tippen
|
||||||
|
2. Zufällige Pause (min_pause bis max_pause) für natürliches Tempo
|
||||||
|
3. Bild vom ESP32 holen und hochladen
|
||||||
|
4. [TICK] senden
|
||||||
|
|
||||||
|
Bei zu vielen Fehlern in Folge stoppt die Bridge.
|
||||||
|
|
||||||
|
Wenn auto_tick=false in config, werden keine TICKs gesendet.
|
||||||
|
Das ist der Debug-Modus - du sendest [TICK] dann manuell im Chat.
|
||||||
|
"""
|
||||||
|
hb_config = self.config.get("heartbeat", {})
|
||||||
|
auto_tick = hb_config.get("auto_tick", True)
|
||||||
|
upload_images = hb_config.get("upload_images", True) # Bilder hochladen?
|
||||||
|
max_errors = hb_config.get("max_consecutive_errors", 5)
|
||||||
|
check_interval = hb_config.get("check_interval", 1)
|
||||||
|
min_pause = hb_config.get("min_pause", 2)
|
||||||
|
max_pause = hb_config.get("max_pause", 4)
|
||||||
|
|
||||||
|
# Debug-Modus: Keine automatischen TICKs
|
||||||
|
if not auto_tick:
|
||||||
|
console.print("\n[yellow]DEBUG-MODUS: Automatische TICKs deaktiviert![/yellow]")
|
||||||
|
console.print("[dim]Sende [TICK] manuell im Claude.ai Chat um fortzufahren.[/dim]\n")
|
||||||
|
logger.info("Heartbeat deaktiviert (auto_tick=false)")
|
||||||
|
return
|
||||||
|
|
||||||
|
logger.info(f"Heartbeat gestartet (Pause: {min_pause}-{max_pause}s, max {max_errors} Fehler)")
|
||||||
|
|
||||||
|
# ════════════════════════════════════════════════════════════════
|
||||||
|
# WICHTIG: Warte auf [READY] bevor TICKs gesendet werden!
|
||||||
|
# ════════════════════════════════════════════════════════════════
|
||||||
|
console.print("[dim]Heartbeat wartet auf [READY]...[/dim]")
|
||||||
|
self._claude_ready.wait() # Blockiert bis _send_start_signal() das Event setzt
|
||||||
|
console.print("[green]Heartbeat startet![/green]")
|
||||||
|
|
||||||
|
while self.running:
|
||||||
|
try:
|
||||||
|
# Warte bis Claude fertig ist mit Tippen
|
||||||
|
while self.running and self.chat.is_claude_typing():
|
||||||
|
logger.debug("Claude tippt noch, warte...")
|
||||||
|
time.sleep(check_interval)
|
||||||
|
|
||||||
|
if not self.running:
|
||||||
|
break
|
||||||
|
|
||||||
|
# Zufällige Pause nach Claudes Antwort (natürlicheres Tempo)
|
||||||
|
pause = random.uniform(min_pause, max_pause)
|
||||||
|
time.sleep(pause)
|
||||||
|
|
||||||
|
if not self.running:
|
||||||
|
break
|
||||||
|
|
||||||
|
# Stefan-Buffer holen (falls er was gesagt hat)
|
||||||
|
stefan_text = self._get_and_clear_stefan_buffer()
|
||||||
|
|
||||||
|
# Nächsten TICK senden (mit oder ohne Bild)
|
||||||
|
with self._lock:
|
||||||
|
# Erst Bild hochladen wenn aktiviert
|
||||||
|
if upload_images:
|
||||||
|
# Bild holen und hochladen
|
||||||
|
if not self.chat.fetch_image_from_esp32():
|
||||||
|
logger.warning("Konnte kein Bild vom ESP32 holen")
|
||||||
|
elif not self.chat.upload_image_to_chat():
|
||||||
|
logger.warning("Konnte Bild nicht hochladen")
|
||||||
|
|
||||||
|
# Nachricht zusammenbauen
|
||||||
|
if stefan_text:
|
||||||
|
# Stefan hat was gesagt → Mit TICK senden
|
||||||
|
tick_message = f"[TICK]\n\nStefan sagt: {stefan_text}"
|
||||||
|
console.print(f"[cyan]→ TICK mit Stefan-Buffer: \"{stefan_text[:50]}...\"[/cyan]" if len(stefan_text) > 50 else f"[cyan]→ TICK mit Stefan-Buffer: \"{stefan_text}\"[/cyan]")
|
||||||
|
else:
|
||||||
|
# Nur TICK
|
||||||
|
tick_message = "[TICK]"
|
||||||
|
|
||||||
|
success = self.chat.send_message(tick_message)
|
||||||
|
|
||||||
|
if success:
|
||||||
|
self.stats.ticks_sent += 1
|
||||||
|
self.stats.consecutive_errors = 0 # Reset
|
||||||
|
logger.debug(f"TICK #{self.stats.ticks_sent}" + (" mit Bild" if upload_images else "") + (f" + Stefan: {stefan_text[:30]}" if stefan_text else ""))
|
||||||
|
else:
|
||||||
|
raise Exception("TICK fehlgeschlagen")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Heartbeat-Fehler: {e}")
|
||||||
|
self.stats.errors += 1
|
||||||
|
self.stats.consecutive_errors += 1
|
||||||
|
|
||||||
|
# Bei zu vielen Fehlern: Bridge stoppen
|
||||||
|
if self.stats.consecutive_errors >= max_errors:
|
||||||
|
console.print(f"\n[bold red]FEHLER: {max_errors} Fehler in Folge![/bold red]")
|
||||||
|
console.print("[red]Chat nicht erreichbar - stoppe Bridge.[/red]")
|
||||||
|
self.running = False
|
||||||
|
break
|
||||||
|
|
||||||
|
# Warte etwas länger bei Fehlern
|
||||||
|
time.sleep(5)
|
||||||
|
|
||||||
|
def _tts_loop(self):
|
||||||
|
"""
|
||||||
|
Liest neue Claude-Nachrichten vor.
|
||||||
|
|
||||||
|
Filtert dabei [BEFEHLE] und technische Teile raus,
|
||||||
|
sodass nur der "menschliche" Text gesprochen wird.
|
||||||
|
"""
|
||||||
|
if not self.tts:
|
||||||
|
logger.warning("TTS nicht verfügbar")
|
||||||
|
return
|
||||||
|
|
||||||
|
logger.info("TTS-Loop gestartet")
|
||||||
|
|
||||||
|
while self.running:
|
||||||
|
try:
|
||||||
|
# Hole neue Nachrichten
|
||||||
|
messages = self.chat.get_new_messages(since_id=self.last_assistant_message_id)
|
||||||
|
|
||||||
|
for msg in messages:
|
||||||
|
if msg.is_from_assistant:
|
||||||
|
self.last_assistant_message_id = msg.id
|
||||||
|
|
||||||
|
# Text für Sprache aufbereiten
|
||||||
|
speech_text = self._clean_for_speech(msg.text)
|
||||||
|
|
||||||
|
if speech_text and len(speech_text) > 5:
|
||||||
|
# In Konsole anzeigen
|
||||||
|
console.print(f"\n[bold blue]Claude:[/bold blue] {speech_text[:200]}")
|
||||||
|
if len(speech_text) > 200:
|
||||||
|
console.print(f"[dim]...({len(speech_text)} Zeichen)[/dim]")
|
||||||
|
|
||||||
|
# Vorlesen
|
||||||
|
self.tts.speak(speech_text)
|
||||||
|
self.stats.messages_spoken += 1
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"TTS-Loop-Fehler: {e}")
|
||||||
|
self.stats.errors += 1
|
||||||
|
|
||||||
|
time.sleep(0.5)
|
||||||
|
|
||||||
|
def _stt_loop(self):
|
||||||
|
"""
|
||||||
|
Hört auf Stefan und sammelt seine Worte im Buffer.
|
||||||
|
|
||||||
|
Wenn Claude tippt → Buffer sammeln
|
||||||
|
Wenn Claude fertig → Buffer wird mit nächstem TICK gesendet
|
||||||
|
|
||||||
|
So wird Claude nicht unterbrochen und bekommt alles gesammelt.
|
||||||
|
"""
|
||||||
|
if not self.stt:
|
||||||
|
logger.warning("STT nicht verfügbar")
|
||||||
|
return
|
||||||
|
|
||||||
|
logger.info("STT-Loop gestartet (mit Buffer)")
|
||||||
|
|
||||||
|
while self.running:
|
||||||
|
try:
|
||||||
|
# Warte auf Sprache (mit Timeout)
|
||||||
|
result = self.stt.listen_once(timeout=2)
|
||||||
|
|
||||||
|
if result and result.text and len(result.text) > 2:
|
||||||
|
# In Buffer speichern (thread-safe)
|
||||||
|
with self._stefan_buffer_lock:
|
||||||
|
self._stefan_buffer.append(result.text)
|
||||||
|
self.stats.stefan_inputs += 1
|
||||||
|
|
||||||
|
console.print(f"\n[bold green]Stefan (gebuffert):[/bold green] {result.text}")
|
||||||
|
logger.debug(f"Stefan-Buffer: {len(self._stefan_buffer)} Einträge")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
# Timeout ist normal
|
||||||
|
if "timeout" not in str(e).lower():
|
||||||
|
logger.error(f"STT-Loop-Fehler: {e}")
|
||||||
|
self.stats.errors += 1
|
||||||
|
|
||||||
|
def _get_and_clear_stefan_buffer(self) -> Optional[str]:
|
||||||
|
"""
|
||||||
|
Holt den Stefan-Buffer und leert ihn.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Zusammengefasster Text oder None wenn Buffer leer
|
||||||
|
"""
|
||||||
|
with self._stefan_buffer_lock:
|
||||||
|
if not self._stefan_buffer:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Alles zusammenfassen
|
||||||
|
text = " ".join(self._stefan_buffer)
|
||||||
|
self._stefan_buffer = []
|
||||||
|
|
||||||
|
return text
|
||||||
|
|
||||||
|
def _clean_for_speech(self, text: str) -> str:
|
||||||
|
"""
|
||||||
|
Entfernt Befehle und technische Teile aus dem Text.
|
||||||
|
|
||||||
|
Was rausgefiltert wird:
|
||||||
|
- [TICK], [START] und andere Marker
|
||||||
|
- [FORWARD], [LEFT] etc. Fahrbefehle
|
||||||
|
- [LOOK_LEFT] etc. Kamerabefehle
|
||||||
|
- *Aktionen* in Sternchen
|
||||||
|
- API-Call Beschreibungen
|
||||||
|
"""
|
||||||
|
# Marker entfernen
|
||||||
|
text = re.sub(r'\[TICK\]', '', text)
|
||||||
|
text = re.sub(r'\[START\]', '', text)
|
||||||
|
|
||||||
|
# Fahrbefehle entfernen
|
||||||
|
text = re.sub(r'\[(FORWARD|BACKWARD|LEFT|RIGHT|STOP)\]', '', text)
|
||||||
|
|
||||||
|
# Kamerabefehle entfernen
|
||||||
|
text = re.sub(r'\[(LOOK_LEFT|LOOK_RIGHT|LOOK_UP|LOOK_DOWN|LOOK_CENTER)\]', '', text)
|
||||||
|
|
||||||
|
# Aktionen in Sternchen entfernen (*holt Bild*, *schaut*, etc.)
|
||||||
|
text = re.sub(r'\*[^*]+\*', '', text)
|
||||||
|
|
||||||
|
# API-Calls entfernen
|
||||||
|
text = re.sub(r'(GET|POST)\s+/api/\S+', '', text)
|
||||||
|
text = re.sub(r'web_fetch\([^)]+\)', '', text)
|
||||||
|
|
||||||
|
# Code-Blöcke entfernen
|
||||||
|
text = re.sub(r'```[^`]+```', '', text)
|
||||||
|
text = re.sub(r'`[^`]+`', '', text)
|
||||||
|
|
||||||
|
# URLs entfernen (optional, könnte man auch lassen)
|
||||||
|
# text = re.sub(r'https?://\S+', '', text)
|
||||||
|
|
||||||
|
# Mehrfache Leerzeichen/Zeilenumbrüche bereinigen
|
||||||
|
text = re.sub(r'\n\s*\n', '\n', text)
|
||||||
|
text = re.sub(r' +', ' ', text)
|
||||||
|
|
||||||
|
return text.strip()
|
||||||
|
|
||||||
|
def _print_status(self):
|
||||||
|
"""Gibt Status in regelmäßigen Abständen aus (optional)"""
|
||||||
|
# Könnte hier eine Live-Statusanzeige einbauen
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def signal_handler(signum, frame):
|
||||||
|
"""Behandelt Ctrl+C"""
|
||||||
|
console.print("\n[yellow]Signal empfangen, beende...[/yellow]")
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
|
||||||
|
@click.command()
|
||||||
|
@click.option('--config', '-c', default='config.yaml', help='Pfad zur Config-Datei')
|
||||||
|
@click.option('--test', is_flag=True, help='Nur Test-Modus (kein Heartbeat)')
|
||||||
|
@click.option('--debug', '-d', is_flag=True, help='Debug-Logging aktivieren')
|
||||||
|
def main(config: str, test: bool, debug: bool):
|
||||||
|
"""
|
||||||
|
Claude's Eyes - Audio Bridge
|
||||||
|
|
||||||
|
Verbindet Claude.ai Chat mit Audio (TTS/STT).
|
||||||
|
Claude steuert den Roboter SELBST - wir machen nur Audio!
|
||||||
|
"""
|
||||||
|
|
||||||
|
if debug:
|
||||||
|
logging.getLogger().setLevel(logging.DEBUG)
|
||||||
|
|
||||||
|
# Signal Handler
|
||||||
|
signal.signal(signal.SIGINT, signal_handler)
|
||||||
|
signal.signal(signal.SIGTERM, signal_handler)
|
||||||
|
|
||||||
|
# Config-Pfad finden
|
||||||
|
config_path = Path(config)
|
||||||
|
if not config_path.is_absolute():
|
||||||
|
script_dir = Path(__file__).parent
|
||||||
|
if (script_dir / config).exists():
|
||||||
|
config_path = script_dir / config
|
||||||
|
|
||||||
|
# Bridge erstellen und starten
|
||||||
|
bridge = ClaudesEyesAudioBridge(str(config_path))
|
||||||
|
|
||||||
|
if bridge.initialize():
|
||||||
|
if test:
|
||||||
|
console.print("[yellow]Test-Modus - kein automatischer Start[/yellow]")
|
||||||
|
console.print("Drücke Enter um eine Test-Nachricht zu senden...")
|
||||||
|
input()
|
||||||
|
bridge.chat.send_message("[TEST] Das ist ein Test der Audio Bridge!")
|
||||||
|
console.print("Warte 10 Sekunden auf Antwort...")
|
||||||
|
time.sleep(10)
|
||||||
|
bridge.stop()
|
||||||
|
else:
|
||||||
|
bridge.start()
|
||||||
|
else:
|
||||||
|
console.print("[red]Initialisierung fehlgeschlagen![/red]")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
|
|
@ -0,0 +1,816 @@
|
||||||
|
"""
|
||||||
|
Claude's Eyes - Chat Web Interface
|
||||||
|
|
||||||
|
Steuert den echten Claude.ai Chat im Browser via Selenium.
|
||||||
|
Claude (im Chat) steuert den Roboter SELBST - diese Bridge ist nur für Audio!
|
||||||
|
|
||||||
|
HINWEIS: Die CSS-Selektoren müssen möglicherweise angepasst werden,
|
||||||
|
wenn Claude.ai sein UI ändert.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import time
|
||||||
|
import logging
|
||||||
|
import tempfile
|
||||||
|
import requests
|
||||||
|
from requests.adapters import HTTPAdapter
|
||||||
|
from urllib3.util.retry import Retry
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import List, Optional
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from selenium import webdriver
|
||||||
|
from selenium.webdriver.common.by import By
|
||||||
|
from selenium.webdriver.common.keys import Keys
|
||||||
|
from selenium.webdriver.support.ui import WebDriverWait
|
||||||
|
from selenium.webdriver.support import expected_conditions as EC
|
||||||
|
from selenium.webdriver.chrome.service import Service
|
||||||
|
from selenium.common.exceptions import TimeoutException, NoSuchElementException
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ChatMessage:
|
||||||
|
"""Eine Chat-Nachricht"""
|
||||||
|
id: str
|
||||||
|
text: str
|
||||||
|
is_from_assistant: bool
|
||||||
|
timestamp: float = 0
|
||||||
|
|
||||||
|
|
||||||
|
class ClaudeChatInterface:
|
||||||
|
"""
|
||||||
|
Steuert Claude.ai Chat via Selenium Browser Automation.
|
||||||
|
|
||||||
|
Diese Klasse:
|
||||||
|
- Öffnet einen Browser mit dem Claude.ai Chat
|
||||||
|
- Kann Nachrichten senden (für Heartbeat und Stefan's Sprache)
|
||||||
|
- Kann neue Nachrichten lesen (für TTS)
|
||||||
|
|
||||||
|
WICHTIG: Du musst beim ersten Start manuell einloggen!
|
||||||
|
"""
|
||||||
|
|
||||||
|
# CSS Selektoren für Claude.ai (Stand: Dezember 2025)
|
||||||
|
# Diese müssen angepasst werden wenn sich das UI ändert!
|
||||||
|
SELECTORS = {
|
||||||
|
# Eingabefeld für neue Nachrichten
|
||||||
|
"input_field": "div.ProseMirror[contenteditable='true']",
|
||||||
|
|
||||||
|
# Alternativ: Textarea
|
||||||
|
"input_textarea": "textarea[placeholder*='Message']",
|
||||||
|
|
||||||
|
# Senden-Button (falls Enter nicht funktioniert)
|
||||||
|
"send_button": "button[aria-label*='Send']",
|
||||||
|
|
||||||
|
# Alle Nachrichten-Container
|
||||||
|
"messages_container": "div[class*='conversation']",
|
||||||
|
|
||||||
|
# Einzelne Nachrichten
|
||||||
|
"message_human": "div[data-is-streaming='false'][class*='human']",
|
||||||
|
"message_assistant": "div[data-is-streaming='false'][class*='assistant']",
|
||||||
|
|
||||||
|
# Generischer Nachrichten-Selektor (Fallback)
|
||||||
|
"message_any": "div[class*='message']",
|
||||||
|
|
||||||
|
# Streaming-Indikator (Claude tippt noch)
|
||||||
|
"streaming": "div[data-is-streaming='true']",
|
||||||
|
|
||||||
|
# File Upload Input (versteckt, aber funktioniert mit send_keys)
|
||||||
|
"file_input": "input[type='file']",
|
||||||
|
}
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
chat_url: Optional[str] = None,
|
||||||
|
headless: bool = False,
|
||||||
|
user_data_dir: Optional[str] = None,
|
||||||
|
chrome_binary: Optional[str] = None,
|
||||||
|
esp32_url: Optional[str] = None,
|
||||||
|
esp32_api_key: Optional[str] = None
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Initialisiert das Chat-Interface.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
chat_url: URL zum Claude.ai Chat (z.B. https://claude.ai/chat/abc123)
|
||||||
|
headless: Browser im Hintergrund? (False = sichtbar)
|
||||||
|
user_data_dir: Chrome Profil-Ordner (für gespeicherte Logins)
|
||||||
|
chrome_binary: Pfad zur Chrome/Chromium Binary (für Termux)
|
||||||
|
esp32_url: URL zum ESP32/Mock-Server (für Bild-Capture)
|
||||||
|
esp32_api_key: API-Key für ESP32 Authentifizierung
|
||||||
|
"""
|
||||||
|
self.chat_url = chat_url
|
||||||
|
self.esp32_url = esp32_url
|
||||||
|
self.esp32_api_key = esp32_api_key
|
||||||
|
self._message_cache: List[ChatMessage] = []
|
||||||
|
self._last_message_id = 0
|
||||||
|
self._temp_image_path = Path(tempfile.gettempdir()) / "robot_view.jpg"
|
||||||
|
|
||||||
|
# HTTP Session mit größerem Connection Pool (vermeidet "pool full" Warnungen)
|
||||||
|
self._http_session = requests.Session()
|
||||||
|
adapter = HTTPAdapter(pool_connections=10, pool_maxsize=10)
|
||||||
|
self._http_session.mount('http://', adapter)
|
||||||
|
self._http_session.mount('https://', adapter)
|
||||||
|
|
||||||
|
# Chrome Optionen
|
||||||
|
options = webdriver.ChromeOptions()
|
||||||
|
|
||||||
|
if headless:
|
||||||
|
options.add_argument("--headless=new")
|
||||||
|
|
||||||
|
options.add_argument("--no-sandbox")
|
||||||
|
options.add_argument("--disable-dev-shm-usage")
|
||||||
|
options.add_argument("--disable-gpu")
|
||||||
|
options.add_argument("--window-size=1280,800")
|
||||||
|
|
||||||
|
# Für persistente Sessions (Login bleibt gespeichert)
|
||||||
|
if user_data_dir:
|
||||||
|
options.add_argument(f"--user-data-dir={user_data_dir}")
|
||||||
|
|
||||||
|
# Für Termux/Android
|
||||||
|
if chrome_binary:
|
||||||
|
options.binary_location = chrome_binary
|
||||||
|
|
||||||
|
# Anti-Detection (manche Seiten blocken Selenium)
|
||||||
|
options.add_argument("--disable-blink-features=AutomationControlled")
|
||||||
|
options.add_experimental_option("excludeSwitches", ["enable-automation"])
|
||||||
|
options.add_experimental_option("useAutomationExtension", False)
|
||||||
|
|
||||||
|
logger.info("Starte Chrome Browser...")
|
||||||
|
|
||||||
|
try:
|
||||||
|
self.driver = webdriver.Chrome(options=options)
|
||||||
|
self.driver.execute_script(
|
||||||
|
"Object.defineProperty(navigator, 'webdriver', {get: () => undefined})"
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Chrome konnte nicht gestartet werden: {e}")
|
||||||
|
logger.info("Versuche mit webdriver-manager...")
|
||||||
|
from webdriver_manager.chrome import ChromeDriverManager
|
||||||
|
service = Service(ChromeDriverManager().install())
|
||||||
|
self.driver = webdriver.Chrome(service=service, options=options)
|
||||||
|
|
||||||
|
self.wait = WebDriverWait(self.driver, 30)
|
||||||
|
|
||||||
|
# Navigiere zur Chat-URL
|
||||||
|
if chat_url:
|
||||||
|
self.navigate_to_chat(chat_url)
|
||||||
|
|
||||||
|
def navigate_to_chat(self, url: str):
|
||||||
|
"""Navigiert zur Chat-URL"""
|
||||||
|
logger.info(f"Navigiere zu: {url}")
|
||||||
|
self.driver.get(url)
|
||||||
|
self.chat_url = url
|
||||||
|
|
||||||
|
# Warte auf Seitenladung
|
||||||
|
time.sleep(3)
|
||||||
|
|
||||||
|
# Prüfe ob Login nötig
|
||||||
|
if "login" in self.driver.current_url.lower():
|
||||||
|
logger.warning("Login erforderlich! Bitte im Browser einloggen...")
|
||||||
|
print("\n" + "=" * 50)
|
||||||
|
print("BITTE IM BROWSER BEI CLAUDE.AI EINLOGGEN!")
|
||||||
|
print("Das Fenster bleibt offen. Nach dem Login geht's weiter.")
|
||||||
|
print("=" * 50 + "\n")
|
||||||
|
|
||||||
|
# Warte bis wieder auf der Chat-Seite
|
||||||
|
while "login" in self.driver.current_url.lower():
|
||||||
|
time.sleep(2)
|
||||||
|
|
||||||
|
logger.info("Login erfolgreich!")
|
||||||
|
time.sleep(2)
|
||||||
|
|
||||||
|
def send_message(self, text: str, wait_for_response: bool = False) -> bool:
|
||||||
|
"""
|
||||||
|
Sendet eine Nachricht in den Chat.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
text: Die zu sendende Nachricht
|
||||||
|
wait_for_response: Warten bis Claude antwortet?
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True wenn erfolgreich gesendet
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Finde Eingabefeld
|
||||||
|
input_field = self._find_input_field()
|
||||||
|
|
||||||
|
if not input_field:
|
||||||
|
logger.error("Eingabefeld nicht gefunden!")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Feld fokussieren und leeren
|
||||||
|
input_field.click()
|
||||||
|
time.sleep(0.2)
|
||||||
|
|
||||||
|
# Text eingeben
|
||||||
|
input_field.send_keys(text)
|
||||||
|
time.sleep(0.5) # Warte bis Text vollständig eingegeben
|
||||||
|
|
||||||
|
# Versuche Send-Button zu klicken (zuverlässiger als Enter)
|
||||||
|
send_button = self._find_send_button()
|
||||||
|
if send_button:
|
||||||
|
try:
|
||||||
|
send_button.click()
|
||||||
|
logger.debug("Nachricht via Send-Button gesendet")
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"Send-Button Klick fehlgeschlagen: {e}, versuche Enter")
|
||||||
|
input_field.send_keys(Keys.RETURN)
|
||||||
|
else:
|
||||||
|
# Fallback: Enter-Taste
|
||||||
|
logger.debug("Kein Send-Button gefunden, nutze Enter")
|
||||||
|
input_field.send_keys(Keys.RETURN)
|
||||||
|
|
||||||
|
time.sleep(0.3)
|
||||||
|
logger.debug(f"Nachricht gesendet: {text[:50]}...")
|
||||||
|
|
||||||
|
if wait_for_response:
|
||||||
|
self._wait_for_response()
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Fehler beim Senden: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def send_message_with_delay(self, text: str, delay_before_send: int = 15) -> bool:
|
||||||
|
"""
|
||||||
|
Sendet eine Nachricht mit Verzögerung vor dem Absenden.
|
||||||
|
|
||||||
|
Nützlich für große Texte (wie Instruktionen), bei denen die
|
||||||
|
Zwischenablage/das Eingabefeld Zeit braucht um den Text zu verarbeiten.
|
||||||
|
|
||||||
|
Ablauf:
|
||||||
|
1. Text ins Eingabefeld einfügen
|
||||||
|
2. Warte delay_before_send Sekunden
|
||||||
|
3. Send-Button klicken
|
||||||
|
|
||||||
|
Args:
|
||||||
|
text: Die zu sendende Nachricht
|
||||||
|
delay_before_send: Sekunden warten nach Einfügen, vor dem Senden
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True wenn erfolgreich gesendet
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Finde Eingabefeld
|
||||||
|
input_field = self._find_input_field()
|
||||||
|
|
||||||
|
if not input_field:
|
||||||
|
logger.error("Eingabefeld nicht gefunden!")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Feld fokussieren
|
||||||
|
input_field.click()
|
||||||
|
time.sleep(0.2)
|
||||||
|
|
||||||
|
# Text eingeben
|
||||||
|
logger.info(f"Füge Text ein ({len(text)} Zeichen)...")
|
||||||
|
input_field.send_keys(text)
|
||||||
|
|
||||||
|
# WARTEN - große Texte brauchen Zeit!
|
||||||
|
logger.info(f"Warte {delay_before_send}s vor dem Absenden (große Texte brauchen Zeit)...")
|
||||||
|
time.sleep(delay_before_send)
|
||||||
|
|
||||||
|
# Jetzt absenden
|
||||||
|
send_button = self._find_send_button()
|
||||||
|
if send_button:
|
||||||
|
try:
|
||||||
|
send_button.click()
|
||||||
|
logger.info("Nachricht via Send-Button gesendet")
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"Send-Button Klick fehlgeschlagen: {e}, versuche Enter")
|
||||||
|
input_field.send_keys(Keys.RETURN)
|
||||||
|
else:
|
||||||
|
# Fallback: Enter-Taste
|
||||||
|
logger.debug("Kein Send-Button gefunden, nutze Enter")
|
||||||
|
input_field.send_keys(Keys.RETURN)
|
||||||
|
|
||||||
|
time.sleep(0.3)
|
||||||
|
logger.debug(f"Nachricht gesendet: {text[:50]}...")
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Fehler beim Senden mit Verzögerung: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _find_send_button(self):
|
||||||
|
"""Findet den Send-Button"""
|
||||||
|
selectors = [
|
||||||
|
"button[aria-label*='Send']",
|
||||||
|
"button[aria-label*='send']",
|
||||||
|
"button[data-testid*='send']",
|
||||||
|
"button[type='submit']",
|
||||||
|
# Claude.ai spezifisch - Button mit Pfeil-Icon
|
||||||
|
"button svg[class*='send']",
|
||||||
|
"button[class*='send']",
|
||||||
|
]
|
||||||
|
|
||||||
|
for selector in selectors:
|
||||||
|
try:
|
||||||
|
elements = self.driver.find_elements(By.CSS_SELECTOR, selector)
|
||||||
|
for elem in elements:
|
||||||
|
if elem.is_displayed() and elem.is_enabled():
|
||||||
|
return elem
|
||||||
|
except:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Fallback: JavaScript-Suche
|
||||||
|
try:
|
||||||
|
return self.driver.execute_script("""
|
||||||
|
// Suche nach Send-Button
|
||||||
|
const btn = document.querySelector('button[aria-label*="Send"], button[aria-label*="send"]');
|
||||||
|
if (btn && !btn.disabled) return btn;
|
||||||
|
|
||||||
|
// Alternative: Letzter Button im Input-Bereich
|
||||||
|
const buttons = document.querySelectorAll('button');
|
||||||
|
for (const b of buttons) {
|
||||||
|
if (b.offsetParent && !b.disabled) {
|
||||||
|
const text = b.textContent.toLowerCase();
|
||||||
|
const label = (b.getAttribute('aria-label') || '').toLowerCase();
|
||||||
|
if (text.includes('send') || label.includes('send')) return b;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
""")
|
||||||
|
except:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _find_input_field(self):
|
||||||
|
"""Findet das Eingabefeld"""
|
||||||
|
selectors = [
|
||||||
|
self.SELECTORS["input_field"],
|
||||||
|
self.SELECTORS["input_textarea"],
|
||||||
|
"div[contenteditable='true']",
|
||||||
|
"textarea",
|
||||||
|
]
|
||||||
|
|
||||||
|
for selector in selectors:
|
||||||
|
try:
|
||||||
|
element = self.driver.find_element(By.CSS_SELECTOR, selector)
|
||||||
|
if element.is_displayed() and element.is_enabled():
|
||||||
|
return element
|
||||||
|
except NoSuchElementException:
|
||||||
|
continue
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _wait_for_response(self, timeout: int = 60):
|
||||||
|
"""Wartet bis Claude fertig getippt hat"""
|
||||||
|
logger.debug("Warte auf Claudes Antwort...")
|
||||||
|
|
||||||
|
# Warte kurz damit Streaming startet
|
||||||
|
time.sleep(1)
|
||||||
|
|
||||||
|
# Warte bis Streaming endet
|
||||||
|
try:
|
||||||
|
WebDriverWait(self.driver, timeout).until_not(
|
||||||
|
EC.presence_of_element_located(
|
||||||
|
(By.CSS_SELECTOR, self.SELECTORS["streaming"])
|
||||||
|
)
|
||||||
|
)
|
||||||
|
except TimeoutException:
|
||||||
|
logger.warning("Timeout beim Warten auf Antwort")
|
||||||
|
|
||||||
|
time.sleep(0.5) # Kurz warten bis DOM aktualisiert
|
||||||
|
|
||||||
|
def get_new_messages(self, since_id: Optional[str] = None) -> List[ChatMessage]:
|
||||||
|
"""
|
||||||
|
Holt neue Nachrichten aus dem Chat.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
since_id: Nur Nachrichten nach dieser ID zurückgeben
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Liste neuer ChatMessage Objekte
|
||||||
|
"""
|
||||||
|
all_messages = self._get_all_messages()
|
||||||
|
|
||||||
|
if since_id is None:
|
||||||
|
return all_messages
|
||||||
|
|
||||||
|
# Filtere nur neue
|
||||||
|
new_messages = []
|
||||||
|
found_marker = False
|
||||||
|
|
||||||
|
for msg in all_messages:
|
||||||
|
if found_marker:
|
||||||
|
new_messages.append(msg)
|
||||||
|
elif msg.id == since_id:
|
||||||
|
found_marker = True
|
||||||
|
|
||||||
|
return new_messages
|
||||||
|
|
||||||
|
def _get_all_messages(self) -> List[ChatMessage]:
|
||||||
|
"""Holt alle Nachrichten aus dem Chat"""
|
||||||
|
messages = []
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Versuche verschiedene Selektoren
|
||||||
|
elements = []
|
||||||
|
|
||||||
|
# Methode 1: Nach data-is-streaming Attribut
|
||||||
|
try:
|
||||||
|
elements = self.driver.find_elements(
|
||||||
|
By.CSS_SELECTOR,
|
||||||
|
"div[data-is-streaming='false']"
|
||||||
|
)
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Methode 2: Generischer Message-Selektor
|
||||||
|
if not elements:
|
||||||
|
try:
|
||||||
|
elements = self.driver.find_elements(
|
||||||
|
By.CSS_SELECTOR,
|
||||||
|
self.SELECTORS["message_any"]
|
||||||
|
)
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
for i, elem in enumerate(elements):
|
||||||
|
try:
|
||||||
|
text = elem.text.strip()
|
||||||
|
if not text:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Bestimme ob Human oder Assistant
|
||||||
|
class_name = elem.get_attribute("class") or ""
|
||||||
|
is_assistant = (
|
||||||
|
"assistant" in class_name.lower() or
|
||||||
|
"claude" in class_name.lower() or
|
||||||
|
"ai" in class_name.lower()
|
||||||
|
)
|
||||||
|
|
||||||
|
# Generiere ID
|
||||||
|
msg_id = elem.get_attribute("data-message-id")
|
||||||
|
if not msg_id:
|
||||||
|
msg_id = f"msg_{i}_{hash(text[:100])}"
|
||||||
|
|
||||||
|
messages.append(ChatMessage(
|
||||||
|
id=msg_id,
|
||||||
|
text=text,
|
||||||
|
is_from_assistant=is_assistant,
|
||||||
|
timestamp=time.time()
|
||||||
|
))
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"Fehler bei Nachricht {i}: {e}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Fehler beim Lesen der Nachrichten: {e}")
|
||||||
|
|
||||||
|
return messages
|
||||||
|
|
||||||
|
def get_last_assistant_message(self) -> Optional[ChatMessage]:
|
||||||
|
"""Holt die letzte Nachricht von Claude"""
|
||||||
|
messages = self._get_all_messages()
|
||||||
|
|
||||||
|
for msg in reversed(messages):
|
||||||
|
if msg.is_from_assistant:
|
||||||
|
return msg
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
def is_claude_typing(self) -> bool:
|
||||||
|
"""
|
||||||
|
Prüft ob Claude gerade tippt (streaming).
|
||||||
|
|
||||||
|
Erkennt mehrere Indikatoren:
|
||||||
|
1. Stop-Button ist sichtbar (während Claude schreibt)
|
||||||
|
2. data-is-streaming='true' Attribut
|
||||||
|
3. Animiertes Logo / Thinking-Indikator
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Methode 1: Stop-Button prüfen (zuverlässigster Indikator)
|
||||||
|
# Wenn Claude tippt, gibt es einen Stop-Button statt Send-Button
|
||||||
|
stop_indicators = [
|
||||||
|
"button[aria-label*='Stop']",
|
||||||
|
"button[aria-label*='stop']",
|
||||||
|
"button[class*='stop']",
|
||||||
|
"button[data-testid*='stop']",
|
||||||
|
# Alternativer Indikator: Button mit Stop-Icon
|
||||||
|
"button svg[class*='stop']",
|
||||||
|
]
|
||||||
|
|
||||||
|
for selector in stop_indicators:
|
||||||
|
try:
|
||||||
|
elements = self.driver.find_elements(By.CSS_SELECTOR, selector)
|
||||||
|
for elem in elements:
|
||||||
|
if elem.is_displayed():
|
||||||
|
logger.debug(f"Claude tippt (Stop-Button gefunden: {selector})")
|
||||||
|
return True
|
||||||
|
except:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Methode 2: Streaming-Attribut (original)
|
||||||
|
streaming = self.driver.find_elements(
|
||||||
|
By.CSS_SELECTOR,
|
||||||
|
self.SELECTORS["streaming"]
|
||||||
|
)
|
||||||
|
if len(streaming) > 0:
|
||||||
|
logger.debug("Claude tippt (streaming=true)")
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Methode 3: Animiertes/Thinking Indikator suchen
|
||||||
|
thinking_indicators = [
|
||||||
|
"[class*='thinking']",
|
||||||
|
"[class*='loading']",
|
||||||
|
"[class*='typing']",
|
||||||
|
"[class*='streaming']",
|
||||||
|
"[data-state='loading']",
|
||||||
|
# Pulsierendes Logo
|
||||||
|
"[class*='pulse']",
|
||||||
|
"[class*='animate']",
|
||||||
|
]
|
||||||
|
|
||||||
|
for selector in thinking_indicators:
|
||||||
|
try:
|
||||||
|
elements = self.driver.find_elements(By.CSS_SELECTOR, selector)
|
||||||
|
for elem in elements:
|
||||||
|
if elem.is_displayed():
|
||||||
|
logger.debug(f"Claude tippt (Indikator: {selector})")
|
||||||
|
return True
|
||||||
|
except:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Methode 4: JavaScript-basierte Prüfung
|
||||||
|
# Prüft ob irgendwo noch Text gestreamt wird
|
||||||
|
try:
|
||||||
|
is_streaming = self.driver.execute_script("""
|
||||||
|
// Prüfe ob Stop-Button existiert und sichtbar ist
|
||||||
|
const stopBtn = document.querySelector('button[aria-label*="Stop"], button[aria-label*="stop"]');
|
||||||
|
if (stopBtn && stopBtn.offsetParent !== null) return true;
|
||||||
|
|
||||||
|
// Prüfe auf streaming-Attribut
|
||||||
|
const streaming = document.querySelector('[data-is-streaming="true"]');
|
||||||
|
if (streaming) return true;
|
||||||
|
|
||||||
|
// Prüfe auf disabled Send-Button (während Claude tippt)
|
||||||
|
const sendBtn = document.querySelector('button[aria-label*="Send"]');
|
||||||
|
if (sendBtn && sendBtn.disabled) return true;
|
||||||
|
|
||||||
|
return false;
|
||||||
|
""")
|
||||||
|
if is_streaming:
|
||||||
|
logger.debug("Claude tippt (JavaScript-Check)")
|
||||||
|
return True
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"Fehler bei typing-check: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def wait_for_ready_signal(self, timeout: int = 120) -> bool:
|
||||||
|
"""
|
||||||
|
Wartet bis Claude [READY] sendet.
|
||||||
|
|
||||||
|
Sucht nach [READY] das NICHT Teil des Instruktions-Textes ist.
|
||||||
|
Wir zählen wie oft [READY] vorkommt - wenn mehr als 1x, hat Claude geantwortet.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
timeout: Maximale Wartezeit in Sekunden
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True wenn [READY] empfangen, False bei Timeout
|
||||||
|
"""
|
||||||
|
logger.info(f"Warte auf [READY] Signal (max {timeout}s)...")
|
||||||
|
start_time = time.time()
|
||||||
|
|
||||||
|
while time.time() - start_time < timeout:
|
||||||
|
# Warte bis Claude fertig ist mit Tippen
|
||||||
|
typing_wait_start = time.time()
|
||||||
|
while self.is_claude_typing():
|
||||||
|
time.sleep(0.5)
|
||||||
|
# Timeout für typing-wait (max 60s)
|
||||||
|
if time.time() - typing_wait_start > 60:
|
||||||
|
logger.debug("Typing-Wait Timeout, prüfe trotzdem...")
|
||||||
|
break
|
||||||
|
|
||||||
|
# Suche [READY] im Seitentext via JavaScript
|
||||||
|
# Zähle wie oft [READY] vorkommt - 1x ist unsere Instruktion, 2x+ bedeutet Claude hat geantwortet
|
||||||
|
try:
|
||||||
|
ready_count = self.driver.execute_script("""
|
||||||
|
const text = document.body.innerText.toUpperCase();
|
||||||
|
const matches = text.match(/\\[READY\\]/g);
|
||||||
|
return matches ? matches.length : 0;
|
||||||
|
""")
|
||||||
|
|
||||||
|
logger.debug(f"[READY] gefunden: {ready_count}x")
|
||||||
|
|
||||||
|
# Mehr als 1x = Claude hat auch [READY] geschrieben
|
||||||
|
if ready_count and ready_count >= 2:
|
||||||
|
logger.info(f"[READY] Signal gefunden! ({ready_count}x im Text)")
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"JavaScript [READY] Suche fehlgeschlagen: {e}")
|
||||||
|
|
||||||
|
# Kurz warten bevor nächster Check
|
||||||
|
time.sleep(1)
|
||||||
|
|
||||||
|
logger.warning(f"Timeout: Kein [READY] nach {timeout}s")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def take_screenshot(self, path: str = "screenshot.png"):
|
||||||
|
"""Macht einen Screenshot (für Debugging)"""
|
||||||
|
self.driver.save_screenshot(path)
|
||||||
|
logger.info(f"Screenshot gespeichert: {path}")
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
"""Schließt den Browser"""
|
||||||
|
logger.info("Schließe Browser...")
|
||||||
|
try:
|
||||||
|
self.driver.quit()
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# ════════════════════════════════════════════════════════════════════════
|
||||||
|
# BILD-UPLOAD FUNKTIONEN (für Robot Vision)
|
||||||
|
# ════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
def fetch_image_from_esp32(self) -> bool:
|
||||||
|
"""
|
||||||
|
Holt ein Bild vom ESP32/Mock-Server und speichert es lokal.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True wenn erfolgreich, False bei Fehler
|
||||||
|
"""
|
||||||
|
if not self.esp32_url:
|
||||||
|
logger.warning("Keine ESP32 URL konfiguriert")
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Capture-Endpoint aufrufen (macht Foto und gibt es zurück)
|
||||||
|
url = f"{self.esp32_url}/api/capture"
|
||||||
|
if self.esp32_api_key:
|
||||||
|
url += f"?key={self.esp32_api_key}"
|
||||||
|
|
||||||
|
response = self._http_session.get(url, timeout=10)
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
|
# Prüfe ob wir ein Bild bekommen haben
|
||||||
|
content_type = response.headers.get("Content-Type", "")
|
||||||
|
if "image" in content_type:
|
||||||
|
# Direktes Bild
|
||||||
|
with open(self._temp_image_path, "wb") as f:
|
||||||
|
f.write(response.content)
|
||||||
|
logger.info(f"Bild gespeichert: {len(response.content)} bytes")
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
# JSON Response (Mock-Server neuer Stil)
|
||||||
|
# Dann müssen wir /foto.jpg separat holen
|
||||||
|
foto_url = f"{self.esp32_url}/foto.jpg"
|
||||||
|
foto_response = self._http_session.get(foto_url, timeout=10)
|
||||||
|
foto_response.raise_for_status()
|
||||||
|
|
||||||
|
with open(self._temp_image_path, "wb") as f:
|
||||||
|
f.write(foto_response.content)
|
||||||
|
logger.info(f"Bild von /foto.jpg: {len(foto_response.content)} bytes")
|
||||||
|
return True
|
||||||
|
|
||||||
|
except requests.exceptions.RequestException as e:
|
||||||
|
logger.error(f"ESP32 Verbindungsfehler: {e}")
|
||||||
|
return False
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Fehler beim Bild holen: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def upload_image_to_chat(self) -> bool:
|
||||||
|
"""
|
||||||
|
Lädt das gespeicherte Bild in den Claude.ai Chat hoch.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True wenn erfolgreich, False bei Fehler
|
||||||
|
"""
|
||||||
|
if not self._temp_image_path.exists():
|
||||||
|
logger.error("Kein Bild zum Hochladen vorhanden")
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Finde das versteckte file input Element
|
||||||
|
file_input = self._find_file_input()
|
||||||
|
|
||||||
|
if not file_input:
|
||||||
|
logger.error("File-Upload Input nicht gefunden!")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Datei hochladen via send_keys (funktioniert auch bei versteckten Inputs)
|
||||||
|
file_input.send_keys(str(self._temp_image_path.absolute()))
|
||||||
|
|
||||||
|
logger.info("Bild hochgeladen!")
|
||||||
|
|
||||||
|
# Kurz warten bis Upload verarbeitet ist
|
||||||
|
time.sleep(1.5)
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Fehler beim Bild-Upload: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _find_file_input(self):
|
||||||
|
"""Findet das File-Upload Input Element"""
|
||||||
|
selectors = [
|
||||||
|
self.SELECTORS["file_input"],
|
||||||
|
"input[accept*='image']",
|
||||||
|
"input[type='file'][accept]",
|
||||||
|
"input[type='file']",
|
||||||
|
]
|
||||||
|
|
||||||
|
for selector in selectors:
|
||||||
|
try:
|
||||||
|
elements = self.driver.find_elements(By.CSS_SELECTOR, selector)
|
||||||
|
for elem in elements:
|
||||||
|
# Auch versteckte Inputs funktionieren mit send_keys
|
||||||
|
return elem
|
||||||
|
except:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Fallback: Via JavaScript suchen
|
||||||
|
try:
|
||||||
|
return self.driver.execute_script("""
|
||||||
|
return document.querySelector('input[type="file"]') ||
|
||||||
|
document.querySelector('[accept*="image"]');
|
||||||
|
""")
|
||||||
|
except:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def send_tick_with_image(self) -> bool:
|
||||||
|
"""
|
||||||
|
Holt ein Bild vom ESP32, lädt es hoch und sendet [TICK].
|
||||||
|
|
||||||
|
Das ist der Haupt-Heartbeat mit Bild!
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True wenn alles geklappt hat
|
||||||
|
"""
|
||||||
|
# Schritt 1: Bild vom ESP32 holen
|
||||||
|
if not self.fetch_image_from_esp32():
|
||||||
|
# Kein Bild? Trotzdem TICK senden
|
||||||
|
self.send_message("[TICK - KEIN BILD]")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Schritt 2: Bild in Chat hochladen
|
||||||
|
if not self.upload_image_to_chat():
|
||||||
|
self.send_message("[TICK - UPLOAD FEHLGESCHLAGEN]")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Schritt 3: TICK senden
|
||||||
|
self.send_message("[TICK]")
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
# Hilfsfunktion für einfaches Testing
|
||||||
|
def test_interface(chat_url: str):
|
||||||
|
"""Testet das Interface"""
|
||||||
|
import sys
|
||||||
|
|
||||||
|
logging.basicConfig(level=logging.DEBUG)
|
||||||
|
|
||||||
|
print("Starte Chat Interface Test...")
|
||||||
|
print(f"URL: {chat_url}")
|
||||||
|
|
||||||
|
interface = ClaudeChatInterface(
|
||||||
|
chat_url=chat_url,
|
||||||
|
headless=False
|
||||||
|
)
|
||||||
|
|
||||||
|
print("\nChat geöffnet! Drücke Enter um eine Test-Nachricht zu senden...")
|
||||||
|
input()
|
||||||
|
|
||||||
|
interface.send_message("[TEST] Hallo, das ist ein Test der Audio Bridge!")
|
||||||
|
print("Nachricht gesendet!")
|
||||||
|
|
||||||
|
print("\nWarte 5 Sekunden auf Antwort...")
|
||||||
|
time.sleep(5)
|
||||||
|
|
||||||
|
messages = interface.get_new_messages()
|
||||||
|
print(f"\nGefundene Nachrichten: {len(messages)}")
|
||||||
|
|
||||||
|
for msg in messages[-3:]:
|
||||||
|
role = "Claude" if msg.is_from_assistant else "Human"
|
||||||
|
print(f" [{role}] {msg.text[:100]}...")
|
||||||
|
|
||||||
|
print("\nDrücke Enter zum Beenden...")
|
||||||
|
input()
|
||||||
|
|
||||||
|
interface.close()
|
||||||
|
print("Fertig!")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import sys
|
||||||
|
|
||||||
|
if len(sys.argv) < 2:
|
||||||
|
print("Usage: python chat_web_interface.py <claude-chat-url>")
|
||||||
|
print("Example: python chat_web_interface.py https://claude.ai/chat/abc123")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
test_interface(sys.argv[1])
|
||||||
|
|
@ -0,0 +1,135 @@
|
||||||
|
# Claude's Eyes - Audio Bridge Konfiguration v2
|
||||||
|
#
|
||||||
|
# NEUE ARCHITEKTUR:
|
||||||
|
# - Claude (im Browser-Chat) steuert den Roboter SELBST via web_fetch
|
||||||
|
# - Diese Bridge macht NUR Audio (TTS/STT) und Heartbeat
|
||||||
|
#
|
||||||
|
# Kopiere zu config.local.yaml und passe an!
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Chat Interface (Selenium Browser)
|
||||||
|
# ============================================================================
|
||||||
|
chat:
|
||||||
|
# Die URL zu deinem Claude.ai Chat
|
||||||
|
# WICHTIG: Das muss die URL eines bestehenden Chats sein!
|
||||||
|
# Beispiel: https://claude.ai/chat/abc123-def456-...
|
||||||
|
url: "https://claude.ai/chat/21ac7549-1009-44cc-a143-3e4bd3c64b2d"
|
||||||
|
|
||||||
|
# Browser im Hintergrund? (false = du siehst das Fenster)
|
||||||
|
headless: false
|
||||||
|
|
||||||
|
# Chrome Profil-Ordner für persistente Sessions
|
||||||
|
# Wenn gesetzt, bleibt der Login gespeichert
|
||||||
|
user_data_dir: "./chrome_profile"
|
||||||
|
|
||||||
|
# Für Termux/Android: Pfad zur Chrome/Chromium Binary
|
||||||
|
# chrome_binary: "/data/data/com.termux/files/usr/bin/chromium"
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Heartbeat - Hält Claude am Leben
|
||||||
|
# ============================================================================
|
||||||
|
heartbeat:
|
||||||
|
# Automatische TICKs aktivieren?
|
||||||
|
# false = keine automatischen TICKs, du sendest [TICK] manuell im Chat (Debug-Modus)
|
||||||
|
# true = normale Funktion, TICKs werden automatisch gesendet
|
||||||
|
auto_tick: true
|
||||||
|
|
||||||
|
# Bilder mit TICKs hochladen?
|
||||||
|
# true = Bei jedem TICK wird ein Bild vom ESP32 geholt und in den Chat hochgeladen
|
||||||
|
# false = Nur [TICK] ohne Bild (für Debug ohne ESP32)
|
||||||
|
upload_images: true
|
||||||
|
|
||||||
|
# Ablauf: Warten bis Claude fertig → zufällige Pause → Bild holen → TICK senden
|
||||||
|
# So werden keine TICKs gesendet während Claude noch tippt!
|
||||||
|
|
||||||
|
# Pause nach Claudes Antwort (zufällig zwischen min und max)
|
||||||
|
min_pause: 2
|
||||||
|
max_pause: 4
|
||||||
|
|
||||||
|
# Wie oft prüfen ob Claude noch tippt (Sekunden)
|
||||||
|
check_interval: 1
|
||||||
|
|
||||||
|
# Nach wie vielen Fehlern in Folge stoppen?
|
||||||
|
max_consecutive_errors: 5
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Text-to-Speech (Claudes Stimme)
|
||||||
|
# ============================================================================
|
||||||
|
tts:
|
||||||
|
# Engine: "pyttsx3" (offline), "gtts" (Google, online), "termux" (Android)
|
||||||
|
engine: "gtts"
|
||||||
|
|
||||||
|
# Sprache
|
||||||
|
language: "de"
|
||||||
|
|
||||||
|
# Sprechgeschwindigkeit
|
||||||
|
# pyttsx3: Wörter pro Minute (100-200)
|
||||||
|
# gtts: nicht unterstützt
|
||||||
|
# termux: 0.5-2.0 (1.0 = normal)
|
||||||
|
rate: 150
|
||||||
|
|
||||||
|
# Lautstärke (nur pyttsx3)
|
||||||
|
volume: 0.9
|
||||||
|
|
||||||
|
# Stimme (nur pyttsx3) - null = System-Default
|
||||||
|
# Beispiel: "german" oder "de" für deutsche Stimme
|
||||||
|
voice: null
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Speech-to-Text (Stefans Mikrofon)
|
||||||
|
# ============================================================================
|
||||||
|
stt:
|
||||||
|
# Engine: "standard" (SpeechRecognition) oder "termux" (Android)
|
||||||
|
engine: "standard"
|
||||||
|
|
||||||
|
# Erkennungsdienst (nur für standard engine)
|
||||||
|
# "google" (online, gut) oder "sphinx" (offline, mäßig)
|
||||||
|
service: "google"
|
||||||
|
|
||||||
|
# Sprache
|
||||||
|
language: "de-DE"
|
||||||
|
|
||||||
|
# Energie-Schwelle für Spracherkennung
|
||||||
|
# Niedriger = empfindlicher (300 ist Standard)
|
||||||
|
energy_threshold: 300
|
||||||
|
|
||||||
|
# Pause-Schwelle in Sekunden
|
||||||
|
# Wie lange Stille bevor ein Satz als beendet gilt
|
||||||
|
pause_threshold: 0.8
|
||||||
|
|
||||||
|
# Maximale Aufnahmelänge pro Phrase in Sekunden
|
||||||
|
phrase_time_limit: 15
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Termux (Android) Einstellungen
|
||||||
|
# ============================================================================
|
||||||
|
termux:
|
||||||
|
# Nutze Termux:API für TTS/STT statt Python-Libraries
|
||||||
|
# Setzt engine in tts/stt automatisch auf "termux"
|
||||||
|
use_termux_api: false
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# ESP32 Roboter (Referenz für Claude's web_fetch Aufrufe)
|
||||||
|
# ============================================================================
|
||||||
|
# HINWEIS: Diese Werte nutzt CLAUDE direkt im Chat, nicht die Bridge!
|
||||||
|
# Du musst Claude die URL und den API-Key im Chat mitteilen.
|
||||||
|
esp32:
|
||||||
|
# IP-Adresse oder Hostname des Roboters
|
||||||
|
host: "mobil.hacker-net.de"
|
||||||
|
port: 80
|
||||||
|
|
||||||
|
# API-Key für Authentifizierung
|
||||||
|
api_key: "claudes_eyes_secret_2025"
|
||||||
|
|
||||||
|
# Für Zugriff von außen: DynDNS, Tailscale, oder Port-Forward nötig
|
||||||
|
# external_url: "https://mein-roboter.dyndns.org"
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Logging
|
||||||
|
# ============================================================================
|
||||||
|
logging:
|
||||||
|
# Level: DEBUG, INFO, WARNING, ERROR
|
||||||
|
level: "INFO"
|
||||||
|
|
||||||
|
# Log-Datei (relativ zum Script-Verzeichnis)
|
||||||
|
file: "bridge.log"
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 358 KiB |
|
|
@ -0,0 +1,319 @@
|
||||||
|
#!/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/
|
||||||
|
- Simuliert Fahrbefehle (loggt sie)
|
||||||
|
- Liefert Fake-Sensordaten
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
1. Leg JPG-Bilder in ./test_images/ (z.B. Fotos aus deiner Wohnung)
|
||||||
|
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
|
||||||
|
from pathlib import Path
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from flask import Flask, jsonify, send_file, request, Response
|
||||||
|
|
||||||
|
# Logging
|
||||||
|
logging.basicConfig(level=logging.INFO)
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
app = Flask(__name__)
|
||||||
|
|
||||||
|
# Konfiguration
|
||||||
|
IMAGES_DIR = Path(__file__).parent / "test_images"
|
||||||
|
API_KEY = "claudes_eyes_secret_2025"
|
||||||
|
|
||||||
|
# State
|
||||||
|
current_image_index = 0
|
||||||
|
position = {"x": 0, "y": 0, "rotation": 0}
|
||||||
|
camera_angle = {"pan": 90, "tilt": 90}
|
||||||
|
|
||||||
|
|
||||||
|
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!
|
||||||
|
"""
|
||||||
|
global current_image_index
|
||||||
|
|
||||||
|
if not check_api_key():
|
||||||
|
return jsonify({"error": "Invalid API key"}), 401
|
||||||
|
|
||||||
|
# Finde Testbilder
|
||||||
|
if not IMAGES_DIR.exists():
|
||||||
|
IMAGES_DIR.mkdir(parents=True)
|
||||||
|
return jsonify({
|
||||||
|
"error": f"Keine Bilder gefunden! Leg JPGs in {IMAGES_DIR} ab."
|
||||||
|
}), 404
|
||||||
|
|
||||||
|
images = sorted(IMAGES_DIR.glob("*.jpg"))
|
||||||
|
if not images:
|
||||||
|
images = sorted(IMAGES_DIR.glob("*.png"))
|
||||||
|
|
||||||
|
if not images:
|
||||||
|
return jsonify({
|
||||||
|
"error": f"Keine Bilder gefunden! Leg JPGs in {IMAGES_DIR} ab."
|
||||||
|
}), 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.
|
||||||
|
Nach /api/capture liegt das neue Bild hier.
|
||||||
|
"""
|
||||||
|
foto_path = IMAGES_DIR.parent / "foto.jpg"
|
||||||
|
|
||||||
|
if not foto_path.exists():
|
||||||
|
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"""
|
||||||
|
print("""
|
||||||
|
╔══════════════════════════════════════════════════════════════╗
|
||||||
|
║ ║
|
||||||
|
║ 🤖 MOCK ESP32 SERVER - Claude's Eyes ║
|
||||||
|
║ ║
|
||||||
|
║ Simuliert den Roboter für Tests ohne Hardware. ║
|
||||||
|
║ ║
|
||||||
|
╠══════════════════════════════════════════════════════════════╣
|
||||||
|
║ ║
|
||||||
|
║ 1. Leg Testbilder in ./test_images/ ab (JPG oder PNG) ║
|
||||||
|
║ Tipp: Mach 10-20 Fotos aus deiner Wohnung! ║
|
||||||
|
║ ║
|
||||||
|
║ 2. Passe config.yaml an: ║
|
||||||
|
║ esp32: ║
|
||||||
|
║ host: "localhost" ║
|
||||||
|
║ port: 5000 ║
|
||||||
|
║ ║
|
||||||
|
║ 3. Starte die Bridge in einem anderen Terminal ║
|
||||||
|
║ ║
|
||||||
|
╠══════════════════════════════════════════════════════════════╣
|
||||||
|
║ ║
|
||||||
|
║ Server: http://localhost:5000 ║
|
||||||
|
║ API-Key: {api_key} ║
|
||||||
|
║ ║
|
||||||
|
╚══════════════════════════════════════════════════════════════╝
|
||||||
|
""".format(api_key=API_KEY))
|
||||||
|
|
||||||
|
# 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:
|
||||||
|
print(f"⚠️ Keine Bilder in {IMAGES_DIR} gefunden!")
|
||||||
|
print(" Leg dort JPG/PNG-Dateien ab für den Test.\n")
|
||||||
|
|
||||||
|
print("\n🚀 Starte Server...\n")
|
||||||
|
|
||||||
|
app.run(host="0.0.0.0", port=5000, debug=True)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
|
|
@ -0,0 +1,64 @@
|
||||||
|
# Claude's Eyes - Audio Bridge Dependencies v2
|
||||||
|
# Install with: pip install -r requirements.txt
|
||||||
|
#
|
||||||
|
# NEUE ARCHITEKTUR: Claude steuert den Roboter SELBST!
|
||||||
|
# Diese Bridge macht nur Audio (TTS/STT) und Heartbeat.
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Browser Automation (für Claude.ai Chat)
|
||||||
|
# ============================================================================
|
||||||
|
selenium>=4.16.0
|
||||||
|
webdriver-manager>=4.0.1
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Configuration
|
||||||
|
# ============================================================================
|
||||||
|
pyyaml>=6.0.1
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Text-to-Speech
|
||||||
|
# ============================================================================
|
||||||
|
# pyttsx3: Offline, System-Stimmen
|
||||||
|
pyttsx3>=2.90
|
||||||
|
|
||||||
|
# gTTS: Google Text-to-Speech (online, bessere Qualität)
|
||||||
|
gTTS>=2.4.0
|
||||||
|
|
||||||
|
# pygame: Für Audio-Wiedergabe (gTTS braucht das)
|
||||||
|
pygame>=2.5.2
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Speech-to-Text
|
||||||
|
# ============================================================================
|
||||||
|
SpeechRecognition>=3.10.0
|
||||||
|
|
||||||
|
# PyAudio: Mikrofon-Zugriff
|
||||||
|
# Installation kann tricky sein:
|
||||||
|
#
|
||||||
|
# Linux (Debian/Ubuntu):
|
||||||
|
# sudo apt install python3-pyaudio portaudio19-dev
|
||||||
|
# pip install pyaudio
|
||||||
|
#
|
||||||
|
# Windows:
|
||||||
|
# pip install pipwin
|
||||||
|
# pipwin install pyaudio
|
||||||
|
#
|
||||||
|
# Mac:
|
||||||
|
# brew install portaudio
|
||||||
|
# pip install pyaudio
|
||||||
|
#
|
||||||
|
# Termux (Android):
|
||||||
|
# Nutze stattdessen termux.use_termux_api: true in config.yaml
|
||||||
|
# pkg install termux-api
|
||||||
|
#PyAudio>=0.2.13
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# CLI Interface
|
||||||
|
# ============================================================================
|
||||||
|
rich>=13.7.0
|
||||||
|
click>=8.1.7
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Mock ESP32 Server (für Tests ohne Hardware)
|
||||||
|
# ============================================================================
|
||||||
|
flask>=3.0.0
|
||||||
|
|
@ -0,0 +1,136 @@
|
||||||
|
#!/bin/bash
|
||||||
|
# Claude's Eyes - venv Setup & Start Script
|
||||||
|
#
|
||||||
|
# Erstellt/repariert die virtuelle Umgebung und startet die Bridge
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# ./start_venv.sh # Nur venv aktivieren (für manuellen Start)
|
||||||
|
# ./start_venv.sh --run # venv aktivieren + Bridge starten
|
||||||
|
# ./start_venv.sh --reset # venv neu erstellen + Dependencies installieren
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
cd "$SCRIPT_DIR"
|
||||||
|
|
||||||
|
VENV_DIR="$SCRIPT_DIR/venv"
|
||||||
|
|
||||||
|
# Farben
|
||||||
|
RED='\033[0;31m'
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
CYAN='\033[0;36m'
|
||||||
|
NC='\033[0m' # No Color
|
||||||
|
|
||||||
|
echo -e "${CYAN}"
|
||||||
|
echo "╔══════════════════════════════════════════════════════════════╗"
|
||||||
|
echo "║ Claude's Eyes - Python Bridge Setup ║"
|
||||||
|
echo "╚══════════════════════════════════════════════════════════════╝"
|
||||||
|
echo -e "${NC}"
|
||||||
|
|
||||||
|
# Funktion: venv erstellen/reparieren
|
||||||
|
setup_venv() {
|
||||||
|
echo -e "${YELLOW}→ Erstelle virtuelle Umgebung...${NC}"
|
||||||
|
|
||||||
|
# Alte venv löschen falls kaputt
|
||||||
|
if [ -d "$VENV_DIR" ]; then
|
||||||
|
echo -e "${YELLOW} Lösche alte venv...${NC}"
|
||||||
|
rm -rf "$VENV_DIR"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Neue venv erstellen
|
||||||
|
python3 -m venv "$VENV_DIR"
|
||||||
|
|
||||||
|
if [ $? -ne 0 ]; then
|
||||||
|
echo -e "${RED}FEHLER: Konnte venv nicht erstellen!${NC}"
|
||||||
|
echo "Installiere python3-venv: sudo apt install python3-venv"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo -e "${GREEN}✓ venv erstellt${NC}"
|
||||||
|
|
||||||
|
# Aktivieren
|
||||||
|
source "$VENV_DIR/bin/activate"
|
||||||
|
|
||||||
|
# pip upgraden
|
||||||
|
echo -e "${YELLOW}→ Upgrade pip...${NC}"
|
||||||
|
pip install --upgrade pip
|
||||||
|
|
||||||
|
# Dependencies installieren
|
||||||
|
echo -e "${YELLOW}→ Installiere Abhängigkeiten...${NC}"
|
||||||
|
pip install -r requirements.txt
|
||||||
|
|
||||||
|
if [ $? -ne 0 ]; then
|
||||||
|
echo -e "${RED}FEHLER: Konnte Dependencies nicht installieren!${NC}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# PyAudio separat (optional, kann fehlschlagen)
|
||||||
|
echo -e "${YELLOW}→ Versuche PyAudio zu installieren...${NC}"
|
||||||
|
pip install pyaudio 2>/dev/null
|
||||||
|
if [ $? -ne 0 ]; then
|
||||||
|
echo -e "${YELLOW} PyAudio Installation fehlgeschlagen (optional)${NC}"
|
||||||
|
echo -e "${YELLOW} Für STT: sudo apt install python3-pyaudio${NC}"
|
||||||
|
else
|
||||||
|
echo -e "${GREEN}✓ PyAudio installiert${NC}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo -e "${GREEN}════════════════════════════════════════════════════════════════${NC}"
|
||||||
|
echo -e "${GREEN}✓ Setup abgeschlossen!${NC}"
|
||||||
|
echo -e "${GREEN}════════════════════════════════════════════════════════════════${NC}"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Funktion: venv aktivieren
|
||||||
|
activate_venv() {
|
||||||
|
if [ ! -d "$VENV_DIR" ] || [ ! -f "$VENV_DIR/bin/activate" ]; then
|
||||||
|
echo -e "${YELLOW}venv nicht gefunden, erstelle neu...${NC}"
|
||||||
|
setup_venv
|
||||||
|
else
|
||||||
|
source "$VENV_DIR/bin/activate"
|
||||||
|
echo -e "${GREEN}✓ venv aktiviert ($(python --version))${NC}"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Funktion: Bridge starten
|
||||||
|
run_bridge() {
|
||||||
|
echo ""
|
||||||
|
echo -e "${CYAN}→ Starte Audio Bridge...${NC}"
|
||||||
|
echo ""
|
||||||
|
python chat_audio_bridge.py "$@"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Argumente verarbeiten
|
||||||
|
case "$1" in
|
||||||
|
--reset)
|
||||||
|
setup_venv
|
||||||
|
echo ""
|
||||||
|
echo "Starte Bridge mit: ./start_venv.sh --run"
|
||||||
|
;;
|
||||||
|
--run)
|
||||||
|
activate_venv
|
||||||
|
shift # Entferne --run aus den Argumenten
|
||||||
|
run_bridge "$@"
|
||||||
|
;;
|
||||||
|
--help|-h)
|
||||||
|
echo "Usage: ./start_venv.sh [OPTION]"
|
||||||
|
echo ""
|
||||||
|
echo "Optionen:"
|
||||||
|
echo " (keine) Nur venv aktivieren (für source ./start_venv.sh)"
|
||||||
|
echo " --run venv aktivieren und Bridge starten"
|
||||||
|
echo " --reset venv komplett neu erstellen"
|
||||||
|
echo " --help Diese Hilfe anzeigen"
|
||||||
|
echo ""
|
||||||
|
echo "Beispiele:"
|
||||||
|
echo " ./start_venv.sh --reset # Nach Python-Update"
|
||||||
|
echo " ./start_venv.sh --run # Normaler Start"
|
||||||
|
echo " ./start_venv.sh --run -d # Mit Debug-Logging"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
activate_venv
|
||||||
|
echo ""
|
||||||
|
echo -e "${CYAN}venv ist aktiv. Du kannst jetzt:${NC}"
|
||||||
|
echo " python chat_audio_bridge.py # Bridge starten"
|
||||||
|
echo " python mock_esp32.py # Mock-Server starten"
|
||||||
|
echo ""
|
||||||
|
echo -e "${YELLOW}Oder nutze: ./start_venv.sh --run${NC}"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
@ -173,15 +173,148 @@ class STTEngine:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def create_stt_engine(**kwargs) -> STTEngine:
|
class TermuxSTTEngine:
|
||||||
"""Factory function to create STT engine"""
|
"""
|
||||||
return STTEngine(
|
STT via Termux:API für Android
|
||||||
energy_threshold=kwargs.get("energy_threshold", 300),
|
|
||||||
pause_threshold=kwargs.get("pause_threshold", 0.8),
|
Benötigt:
|
||||||
phrase_time_limit=kwargs.get("phrase_time_limit", 15),
|
- Termux App
|
||||||
service=kwargs.get("service", "google"),
|
- Termux:API App
|
||||||
language=kwargs.get("language", "de-DE")
|
- pkg install termux-api
|
||||||
)
|
"""
|
||||||
|
|
||||||
|
def __init__(self, language: str = "de-DE", timeout: int = 10):
|
||||||
|
self.language = language
|
||||||
|
self.timeout = timeout
|
||||||
|
self._listening = False
|
||||||
|
self._stop_flag = False
|
||||||
|
self._thread: Optional[threading.Thread] = None
|
||||||
|
self._callback: Optional[Callable[[SpeechResult], None]] = None
|
||||||
|
|
||||||
|
# Teste ob termux-speech-to-text verfügbar ist
|
||||||
|
import shutil
|
||||||
|
if not shutil.which("termux-speech-to-text"):
|
||||||
|
raise RuntimeError(
|
||||||
|
"termux-speech-to-text nicht gefunden! "
|
||||||
|
"Installiere mit: pkg install termux-api"
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(f"Termux STT engine initialized (language: {language})")
|
||||||
|
|
||||||
|
def listen_once(self, timeout: Optional[float] = None) -> Optional[SpeechResult]:
|
||||||
|
"""
|
||||||
|
Listen for a single phrase via Termux API
|
||||||
|
|
||||||
|
Args:
|
||||||
|
timeout: Maximum time to wait (uses class timeout if None)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
SpeechResult or None if nothing recognized
|
||||||
|
"""
|
||||||
|
import subprocess
|
||||||
|
import json
|
||||||
|
|
||||||
|
actual_timeout = timeout if timeout else self.timeout
|
||||||
|
|
||||||
|
try:
|
||||||
|
# termux-speech-to-text gibt JSON zurück
|
||||||
|
result = subprocess.run(
|
||||||
|
["termux-speech-to-text"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=actual_timeout + 5 # Extra Zeit für API
|
||||||
|
)
|
||||||
|
|
||||||
|
if result.returncode != 0:
|
||||||
|
logger.error(f"Termux STT error: {result.stderr}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Output ist ein String (kein JSON bei Termux)
|
||||||
|
text = result.stdout.strip()
|
||||||
|
|
||||||
|
if text:
|
||||||
|
return SpeechResult(
|
||||||
|
text=text,
|
||||||
|
confidence=0.8, # Termux gibt keine Konfidenz
|
||||||
|
is_final=True
|
||||||
|
)
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
logger.debug("Termux STT timeout")
|
||||||
|
return None
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Termux STT error: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def start_continuous(self, callback: Callable[[SpeechResult], None]) -> None:
|
||||||
|
"""Start continuous listening in background"""
|
||||||
|
if self._listening:
|
||||||
|
logger.warning("Already listening")
|
||||||
|
return
|
||||||
|
|
||||||
|
self._callback = callback
|
||||||
|
self._stop_flag = False
|
||||||
|
self._listening = True
|
||||||
|
|
||||||
|
self._thread = threading.Thread(target=self._listen_loop, daemon=True)
|
||||||
|
self._thread.start()
|
||||||
|
|
||||||
|
logger.info("Termux continuous listening started")
|
||||||
|
|
||||||
|
def stop_continuous(self) -> None:
|
||||||
|
"""Stop continuous listening"""
|
||||||
|
self._stop_flag = True
|
||||||
|
self._listening = False
|
||||||
|
|
||||||
|
if self._thread:
|
||||||
|
self._thread.join(timeout=2)
|
||||||
|
self._thread = None
|
||||||
|
|
||||||
|
logger.info("Termux continuous listening stopped")
|
||||||
|
|
||||||
|
def _listen_loop(self):
|
||||||
|
"""Background thread for continuous listening"""
|
||||||
|
while not self._stop_flag:
|
||||||
|
try:
|
||||||
|
result = self.listen_once(timeout=5)
|
||||||
|
if result and self._callback:
|
||||||
|
self._callback(result)
|
||||||
|
except Exception as e:
|
||||||
|
if not self._stop_flag:
|
||||||
|
logger.error(f"Termux listen loop error: {e}")
|
||||||
|
|
||||||
|
# Kleine Pause zwischen Aufnahmen
|
||||||
|
import time
|
||||||
|
time.sleep(0.5)
|
||||||
|
|
||||||
|
def is_listening(self) -> bool:
|
||||||
|
return self._listening
|
||||||
|
|
||||||
|
|
||||||
|
def create_stt_engine(engine_type: str = "standard", **kwargs):
|
||||||
|
"""
|
||||||
|
Factory function to create STT engine
|
||||||
|
|
||||||
|
Args:
|
||||||
|
engine_type: "standard" or "termux"
|
||||||
|
**kwargs: Engine-specific options
|
||||||
|
"""
|
||||||
|
if engine_type == "termux":
|
||||||
|
return TermuxSTTEngine(
|
||||||
|
language=kwargs.get("language", "de-DE"),
|
||||||
|
timeout=kwargs.get("phrase_time_limit", 15)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# Standard SpeechRecognition engine
|
||||||
|
return STTEngine(
|
||||||
|
energy_threshold=kwargs.get("energy_threshold", 300),
|
||||||
|
pause_threshold=kwargs.get("pause_threshold", 0.8),
|
||||||
|
phrase_time_limit=kwargs.get("phrase_time_limit", 15),
|
||||||
|
service=kwargs.get("service", "google"),
|
||||||
|
language=kwargs.get("language", "de-DE")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# Test when run directly
|
# Test when run directly
|
||||||
|
|
@ -189,12 +189,114 @@ class GTTSEngine(TTSEngine):
|
||||||
return self._speaking
|
return self._speaking
|
||||||
|
|
||||||
|
|
||||||
|
class TermuxTTSEngine(TTSEngine):
|
||||||
|
"""
|
||||||
|
TTS via Termux:API für Android
|
||||||
|
|
||||||
|
Benötigt:
|
||||||
|
- Termux App
|
||||||
|
- Termux:API App
|
||||||
|
- pkg install termux-api
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, language: str = "de", rate: float = 1.0):
|
||||||
|
self.language = language
|
||||||
|
self.rate = rate
|
||||||
|
self._speaking = False
|
||||||
|
self._queue = queue.Queue()
|
||||||
|
self._thread: Optional[threading.Thread] = None
|
||||||
|
self._stop_flag = False
|
||||||
|
self._process = None
|
||||||
|
|
||||||
|
# Teste ob termux-tts-speak verfügbar ist
|
||||||
|
import shutil
|
||||||
|
if not shutil.which("termux-tts-speak"):
|
||||||
|
raise RuntimeError(
|
||||||
|
"termux-tts-speak nicht gefunden! "
|
||||||
|
"Installiere mit: pkg install termux-api"
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(f"Termux TTS engine initialized (language: {language})")
|
||||||
|
|
||||||
|
def speak(self, text: str) -> None:
|
||||||
|
"""Speak text via Termux API (blocking)"""
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
self._speaking = True
|
||||||
|
try:
|
||||||
|
# termux-tts-speak Optionen:
|
||||||
|
# -l <language> - Sprache (z.B. "de" oder "de-DE")
|
||||||
|
# -r <rate> - Geschwindigkeit (0.5 bis 2.0, default 1.0)
|
||||||
|
# -p <pitch> - Tonhöhe (0.5 bis 2.0, default 1.0)
|
||||||
|
# -s <stream> - Audio Stream (ALARM, MUSIC, NOTIFICATION, RING, SYSTEM, VOICE_CALL)
|
||||||
|
|
||||||
|
cmd = [
|
||||||
|
"termux-tts-speak",
|
||||||
|
"-l", self.language,
|
||||||
|
"-r", str(self.rate),
|
||||||
|
text
|
||||||
|
]
|
||||||
|
|
||||||
|
self._process = subprocess.Popen(
|
||||||
|
cmd,
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.PIPE
|
||||||
|
)
|
||||||
|
self._process.wait() # Warte bis fertig
|
||||||
|
self._process = None
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Termux TTS error: {e}")
|
||||||
|
finally:
|
||||||
|
self._speaking = False
|
||||||
|
|
||||||
|
def speak_async(self, text: str) -> None:
|
||||||
|
"""Speak text (non-blocking)"""
|
||||||
|
self._queue.put(text)
|
||||||
|
|
||||||
|
if self._thread is None or not self._thread.is_alive():
|
||||||
|
self._stop_flag = False
|
||||||
|
self._thread = threading.Thread(target=self._speech_worker, daemon=True)
|
||||||
|
self._thread.start()
|
||||||
|
|
||||||
|
def _speech_worker(self):
|
||||||
|
"""Worker thread for async speech"""
|
||||||
|
while not self._stop_flag:
|
||||||
|
try:
|
||||||
|
text = self._queue.get(timeout=0.5)
|
||||||
|
self.speak(text)
|
||||||
|
self._queue.task_done()
|
||||||
|
except queue.Empty:
|
||||||
|
continue
|
||||||
|
|
||||||
|
def stop(self) -> None:
|
||||||
|
"""Stop current speech"""
|
||||||
|
self._stop_flag = True
|
||||||
|
|
||||||
|
# Beende laufenden Prozess
|
||||||
|
if self._process:
|
||||||
|
try:
|
||||||
|
self._process.terminate()
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Clear queue
|
||||||
|
while not self._queue.empty():
|
||||||
|
try:
|
||||||
|
self._queue.get_nowait()
|
||||||
|
except queue.Empty:
|
||||||
|
break
|
||||||
|
|
||||||
|
def is_speaking(self) -> bool:
|
||||||
|
return self._speaking
|
||||||
|
|
||||||
|
|
||||||
def create_tts_engine(engine_type: str = "pyttsx3", **kwargs) -> TTSEngine:
|
def create_tts_engine(engine_type: str = "pyttsx3", **kwargs) -> TTSEngine:
|
||||||
"""
|
"""
|
||||||
Factory function to create TTS engine
|
Factory function to create TTS engine
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
engine_type: "pyttsx3" or "gtts"
|
engine_type: "pyttsx3", "gtts", or "termux"
|
||||||
**kwargs: Engine-specific options
|
**kwargs: Engine-specific options
|
||||||
"""
|
"""
|
||||||
if engine_type == "pyttsx3":
|
if engine_type == "pyttsx3":
|
||||||
|
|
@ -207,6 +309,11 @@ def create_tts_engine(engine_type: str = "pyttsx3", **kwargs) -> TTSEngine:
|
||||||
return GTTSEngine(
|
return GTTSEngine(
|
||||||
language=kwargs.get("language", "de")
|
language=kwargs.get("language", "de")
|
||||||
)
|
)
|
||||||
|
elif engine_type == "termux":
|
||||||
|
return TermuxTTSEngine(
|
||||||
|
language=kwargs.get("language", "de"),
|
||||||
|
rate=kwargs.get("rate", 1.0)
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
raise ValueError(f"Unknown TTS engine: {engine_type}")
|
raise ValueError(f"Unknown TTS engine: {engine_type}")
|
||||||
|
|
||||||
Loading…
Reference in New Issue