From 10de822d444442a52f239f272e4e5ed0718a46c1 Mon Sep 17 00:00:00 2001 From: Matthias Hinrichs Date: Thu, 29 Jan 2026 22:14:00 +0100 Subject: [PATCH] initial commit --- .env.example | 3 + .gitignore | 7 ++ README.md | 17 ++++ app/main.py | 218 +++++++++++++++++++++++++++++++++++++++ requirements.txt | 9 ++ static/index.html | 88 ++++++++++++++++ static/script.js | 107 ++++++++++++++++++++ static/style.css | 253 ++++++++++++++++++++++++++++++++++++++++++++++ 8 files changed, 702 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 README.md create mode 100644 app/main.py create mode 100644 requirements.txt create mode 100644 static/index.html create mode 100644 static/script.js create mode 100644 static/style.css diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..9e33336 --- /dev/null +++ b/.env.example @@ -0,0 +1,3 @@ +BRAIN_URL=http://:8000 +PICOVOICE_API_KEY=your_key_here +CAM_INDEX=0 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..78de10e --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +venv/ +__pycache__/ +*.jpg +*.jpeg +*.png +.env +.DS_Store diff --git a/README.md b/README.md new file mode 100644 index 0000000..473ec18 --- /dev/null +++ b/README.md @@ -0,0 +1,17 @@ +# Home-Bro Client (The Satellite) + +Dies ist der Client-Teil des Home-Bro Projekts, der auf einem Raspberry Pi läuft. + +## Funktionen + +- Wake-Word Erkennung (Porcupine) +- Bildaufnahme (Snapshot) via Webcam +- Upload zum Brain (FastAPI) +- Audio-Wiedergabe der sarkastischen Kommentare + +## Installation + +1. Repository klonen. +2. `pip install -r requirements.txt` +3. `.env` Datei basierend auf `.env.example` erstellen. +4. Starten: `python app/main.py` diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..8725a33 --- /dev/null +++ b/app/main.py @@ -0,0 +1,218 @@ +import os +import time +import asyncio +import requests +from dotenv import load_dotenv, set_key +from fastapi import FastAPI, BackgroundTasks +from fastapi.staticfiles import StaticFiles +from fastapi.responses import FileResponse +from pydantic import BaseModel +import uvicorn + +# Load environment variables +load_dotenv() +ENV_PATH = ".env" + +app = FastAPI(title="Home-Bro Satellite") + +# Ensure static directory exists or serve it +app.mount("/static", StaticFiles(directory="static"), name="static") + +class Config(BaseModel): + bro_name: str + pico_key: str + brain_url: str + wake_word: str + custom_wake_word_path: str = "" + +@app.get("/") +async def read_index(): + return FileResponse("static/index.html") + +@app.get("/config") +async def get_config(): + return { + "bro_name": os.getenv("BRO_NAME", "Home-Bro"), + "pico_key": os.getenv("PICOVOICE_API_KEY", ""), + "brain_url": os.getenv("BRAIN_URL", "http://localhost:8000"), + "wake_word": os.getenv("WAKE_WORD", "porcupine"), + "custom_wake_word_path": os.getenv("CUSTOM_WAKE_WORD_PATH", "") + } + +@app.post("/config") +async def save_config(config: Config): + # Update environment variables in memory and file + settings = { + "BRO_NAME": config.bro_name, + "PICOVOICE_API_KEY": config.pico_key, + "BRAIN_URL": config.brain_url, + "WAKE_WORD": config.wake_word, + "CUSTOM_WAKE_WORD_PATH": config.custom_wake_word_path + } + + for key, value in settings.items(): + os.environ[key] = value + set_key(ENV_PATH, key, value) + + return {"status": "ok"} + +@app.get("/check-brain") +async def check_brain(url: str): + try: + # Simple health check to the brain + # We use a short timeout (2s) to keep UI responsive + response = requests.get(f"{url}/docs", timeout=2) + return {"online": response.status_code == 200} + except Exception: + return {"online": False} + +import cv2 + +def take_snapshot(): + cam_index = int(os.getenv("CAM_INDEX", 0)) + cap = cv2.VideoCapture(cam_index) + if not cap.isOpened(): + print("Error: Could not open camera.") + return None + + ret, frame = cap.read() + if ret: + path = "snapshot.jpg" + cv2.imwrite(path, frame) + cap.release() + return path + cap.release() + return None + +def send_to_brain(image_path): + brain_url = os.getenv("BRAIN_URL") + if not brain_url: + return None + + try: + with open(image_path, "rb") as f: + files = {"file": f} + response = requests.post(f"{brain_url}/analyze", files=files) + if response.status_code == 200: + return response.json() + except Exception as e: + print(f"Upload failed: {e}") + return None + +import pvporcupine +import pyaudio +import struct + +def get_wake_word_params(): + """Helper to get Porcupine parameters based on settings.""" + keyword = os.getenv("WAKE_WORD", "porcupine") + custom_path = os.getenv("CUSTOM_WAKE_WORD_PATH", "") + + if keyword == "custom" and custom_path: + return {"library_path": None, "model_path": None, "keyword_paths": [custom_path]} + + # Standard builtin keywords + if keyword in pvporcupine.KEYWORDS: + return {"keywords": [keyword]} + + return {"keywords": ["porcupine"]} + +# Global variable to hold the task +interaction_task = None + +async def brain_interaction_loop(): + """Background loop for wake-word detection and brain interaction.""" + # RE-LOAD settings inside the loop to ensure we use latest + access_key = os.getenv("PICOVOICE_API_KEY") + if not access_key: + print("CRITICAL: PICOVOICE_API_KEY not found in .env. Wake-word detection disabled.") + return + + pa = None + audio_stream = None + porcupine = None + + try: + params = get_wake_word_params() + porcupine = pvporcupine.create(access_key=access_key, **params) + + pa = pyaudio.PyAudio() + audio_stream = pa.open( + rate=porcupine.sample_rate, + channels=1, + format=pyaudio.paInt16, + input=True, + frames_per_buffer=porcupine.frame_length + ) + + print(f"Listening for wake-word '{os.getenv('WAKE_WORD', 'porcupine')}'...") + + while True: + # Check for cancellation + await asyncio.sleep(0.01) + + pcm = audio_stream.read(porcupine.frame_length, exception_on_overflow=False) + pcm = struct.unpack_from("h" * porcupine.frame_length, pcm) + + keyword_index = porcupine.process(pcm) + + if keyword_index >= 0: + print(f"[{os.getenv('BRO_NAME', 'Home-Bro')}] Wake-word detected!") + img_path = take_snapshot() + if img_path: + result = send_to_brain(img_path) + if result: + print(f"Brain says: {result.get('comment')}") + + except asyncio.CancelledError: + print("Wake-word loop cancelled for restart...") + except Exception as e: + print(f"Error in wake-word loop: {e}") + finally: + if audio_stream is not None: + audio_stream.close() + if pa is not None: + pa.terminate() + if porcupine is not None: + porcupine.delete() + +def start_interaction_loop(): + global interaction_task + interaction_task = asyncio.create_task(brain_interaction_loop()) + +async def restart_interaction_loop(): + global interaction_task + if interaction_task: + interaction_task.cancel() + try: + await interaction_task + except asyncio.CancelledError: + pass + start_interaction_loop() + +@app.post("/config") +async def save_config(config: Config, background_tasks: BackgroundTasks): + # Update environment variables in memory and file + settings = { + "BRO_NAME": config.bro_name, + "PICOVOICE_API_KEY": config.pico_key, + "BRAIN_URL": config.brain_url, + "WAKE_WORD": config.wake_word, + "CUSTOM_WAKE_WORD_PATH": config.custom_wake_word_path + } + + for key, value in settings.items(): + os.environ[key] = value + set_key(ENV_PATH, key, value) + + # Trigger restart in the background + background_tasks.add_task(restart_interaction_loop) + + return {"status": "ok"} + +@app.on_event("startup") +async def startup_event(): + start_interaction_loop() + +if __name__ == "__main__": + uvicorn.run(app, host="0.0.0.0", port=8080) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..3f0f36e --- /dev/null +++ b/requirements.txt @@ -0,0 +1,9 @@ +requests +pvporcupine +pyaudio +python-dotenv +pygame +opencv-python-headless +fastapi +uvicorn +python-multipart diff --git a/static/index.html b/static/index.html new file mode 100644 index 0000000..d40d47a --- /dev/null +++ b/static/index.html @@ -0,0 +1,88 @@ + + + + + + Home-Bro Client | Satellite Config + + + + + + +
+
+
+ +
Verbindung prüfen...
+
+ +
+

Konfiguration

+

Verbinde diesen Satelliten mit dem Gehirn.

+ +
+ + + Wie soll dein Mitbewohner heißen? +
+ +
+ + + Benötigt für die Wake-Word Erkennung. +
+ +
+ + + Die IP-Adresse deines Home-Bro Brains. +
+ +
+ + + +
+ + +
+ +
+

Systemstatus

+
+
+ Uptime + 00:00:00 +
+
+ Kamera + Inaktiv +
+
+
+ +
Gespeichert!
+
+ + + diff --git a/static/script.js b/static/script.js new file mode 100644 index 0000000..655d6e0 --- /dev/null +++ b/static/script.js @@ -0,0 +1,107 @@ +const broNameInput = document.getElementById('bro-name'); +const picoKeyInput = document.getElementById('pico-key'); +const brainUrlInput = document.getElementById('brain-url'); +const wakeWordSelect = document.getElementById('wake-word'); +const customWakeWordInput = document.getElementById('custom-wake-word'); +const saveBtn = document.getElementById('save-btn'); +const statusBadge = document.getElementById('status-badge'); +const toast = document.getElementById('toast'); +const uptimeEl = document.getElementById('uptime'); + +// Toggle custom wake-word input +wakeWordSelect.addEventListener('change', () => { + customWakeWordInput.style.display = wakeWordSelect.value === 'custom' ? 'block' : 'none'; +}); + +async function fetchConfig() { + try { + const res = await fetch('/config'); + const data = await res.json(); + broNameInput.value = data.bro_name || ''; + picoKeyInput.value = data.pico_key || ''; + brainUrlInput.value = data.brain_url || ''; + wakeWordSelect.value = data.wake_word || 'porcupine'; + customWakeWordInput.value = data.custom_wake_word_path || ''; + + if (wakeWordSelect.value === 'custom') { + customWakeWordInput.style.display = 'block'; + } + + checkBrainStatus(data.brain_url); + } catch (err) { + console.error('Failed to fetch config', err); + } +} + +async function checkBrainStatus(url) { + if (!url) { + statusBadge.textContent = 'Bereit'; + statusBadge.className = 'badge checking'; + return; + } + + try { + const res = await fetch(`/check-brain?url=${encodeURIComponent(url)}`); + const data = await res.json(); + if (data.online) { + statusBadge.textContent = 'Brain Online'; + statusBadge.className = 'badge online'; + } else { + statusBadge.textContent = 'Brain Offline'; + statusBadge.className = 'badge offline'; + showToast('Brain konnte nicht erreicht werden!', true); + } + } catch (err) { + statusBadge.textContent = 'Brain Offline'; + statusBadge.className = 'badge offline'; + showToast('Verbindung zum Brain fehlgeschlagen!', true); + } +} + +saveBtn.addEventListener('click', async () => { + saveBtn.classList.add('loading'); + + const config = { + bro_name: broNameInput.value, + pico_key: picoKeyInput.value, + brain_url: brainUrlInput.value, + wake_word: wakeWordSelect.value, + custom_wake_word_path: customWakeWordInput.value + }; + + try { + const res = await fetch('/config', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(config) + }); + + if (res.ok) { + showToast('Einstellungen gespeichert!'); + checkBrainStatus(config.brain_url); + } + } catch (err) { + showToast('Fehler beim Speichern!', true); + } finally { + saveBtn.classList.remove('loading'); + } +}); + +function showToast(msg, isError = false) { + toast.textContent = msg; + toast.style.background = isError ? 'var(--error)' : 'var(--success)'; + toast.classList.add('show'); + setTimeout(() => toast.classList.remove('show'), 3000); +} + +// Simple uptime ticker +let seconds = 0; +setInterval(() => { + seconds++; + const h = Math.floor(seconds / 3600).toString().padStart(2, '0'); + const m = Math.floor((seconds % 3600) / 60).toString().padStart(2, '0'); + const s = (seconds % 60).toString().padStart(2, '0'); + uptimeEl.textContent = `${h}:${m}:${s}`; +}, 1000); + +fetchConfig(); diff --git a/static/style.css b/static/style.css new file mode 100644 index 0000000..90c01d5 --- /dev/null +++ b/static/style.css @@ -0,0 +1,253 @@ +:root { + --bg-color: #0d0f14; + --card-bg: rgba(255, 255, 255, 0.05); + --card-border: rgba(255, 255, 255, 0.1); + --primary: #6366f1; + --primary-hover: #4f46e5; + --text-main: #f8fafc; + --text-dim: #94a3b8; + --success: #22c55e; + --warning: #eab308; + --error: #ef4444; +} + +* { + margin: 0; + padding: 0; + box-sizing: border-box; + font-family: "Inter", sans-serif; +} + +body { + background-color: var(--bg-color); + color: var(--text-main); + min-height: 100vh; + display: flex; + justify-content: center; + align-items: center; + overflow: hidden; +} + +.background-glow { + position: absolute; + width: 600px; + height: 600px; + background: radial-gradient( + circle, + rgba(99, 102, 241, 0.15) 0%, + rgba(99, 102, 241, 0) 70% + ); + top: -100px; + right: -100px; + z-index: -1; + pointer-events: none; +} + +.container { + width: 100%; + max-width: 480px; + padding: 20px; + z-index: 1; +} + +header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 32px; +} + +.logo h1 { + font-family: "Outfit", sans-serif; + font-size: 1.5rem; + font-weight: 700; +} + +.logo h1 span { + font-weight: 400; + color: var(--text-dim); +} + +.badge { + padding: 6px 12px; + border-radius: 20px; + font-size: 0.75rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.badge.checking { + background: rgba(148, 163, 184, 0.1); + color: var(--text-dim); +} +.badge.online { + background: rgba(34, 197, 94, 0.1); + color: var(--success); +} +.badge.offline { + background: rgba(239, 68, 68, 0.1); + color: var(--error); +} + +.card { + background: var(--card-bg); + backdrop-filter: blur(12px); + border: 1px solid var(--card-border); + border-radius: 24px; + padding: 32px; + margin-bottom: 20px; + box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5); + transition: transform 0.3s ease; +} + +.card:hover { + transform: translateY(-4px); +} + +h2 { + font-family: "Outfit", sans-serif; + font-size: 1.25rem; + margin-bottom: 8px; +} + +.subtitle { + color: var(--text-dim); + font-size: 0.9rem; + margin-bottom: 24px; +} + +.input-group { + margin-bottom: 24px; +} + +label { + display: block; + font-size: 0.8rem; + font-weight: 600; + color: var(--text-dim); + margin-bottom: 8px; + text-transform: uppercase; +} + +input, select { + width: 100%; + padding: 14px 18px; + background: rgba(0, 0, 0, 0.2); + border: 1px solid var(--card-border); + border-radius: 12px; + color: white; + font-size: 1rem; + transition: all 0.2s ease; + appearance: none; +} + +select { + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24' stroke='white'%3E%3Cpath stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M19 9l-7 7-7-7'%3E%3C/path%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 18px center; + background-size: 16px; +} + +input:focus, select:focus { + outline: none; + border-color: var(--primary); + box-shadow: 0 0 0 4px rgba(99, 102, 241, 0.1); +} + +.input-hint { + display: block; + font-size: 0.75rem; + color: var(--text-dim); + margin-top: 6px; +} + +.primary-btn { + width: 100%; + padding: 16px; + background: var(--primary); + border: none; + border-radius: 12px; + color: white; + font-size: 1rem; + font-weight: 600; + cursor: pointer; + transition: all 0.2s ease; + display: flex; + justify-content: center; + align-items: center; + gap: 10px; +} + +.primary-btn:hover { + background: var(--primary-hover); + transform: scale(1.02); +} + +.primary-btn:active { + transform: scale(0.98); +} + +.card.secondary { + padding: 24px 32px; +} + +.status-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 20px; +} + +.status-item .label { + display: block; + font-size: 0.75rem; + color: var(--text-dim); + margin-bottom: 4px; +} + +.status-item .value { + font-weight: 600; + font-variant-numeric: tabular-nums; +} + +.toast { + position: fixed; + bottom: 30px; + left: 50%; + transform: translateX(-50%) translateY(100px); + background: var(--success); + color: white; + padding: 12px 24px; + border-radius: 12px; + font-weight: 600; + box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1); + transition: transform 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275); +} + +.toast.show { + transform: translateX(-50%) translateY(0); +} + +/* Loader Animation */ +.loader { + display: none; + width: 20px; + height: 20px; + border: 3px solid rgba(255, 255, 255, 0.3); + border-radius: 50%; + border-top-color: #fff; + animation: spin 1s ease-in-out infinite; +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +.loading .loader { + display: block; +} +.loading .btn-text { + display: none; +}