Compare commits
10 Commits
c004bcee24
...
works_on_c
| Author | SHA1 | Date | |
|---|---|---|---|
|
eb822c0318
|
|||
|
acb5e0a209
|
|||
|
4b196c1278
|
|||
|
7e04991d64
|
|||
|
cc3364068a
|
|||
|
c1cbca39bf
|
|||
|
6271f46019
|
|||
|
6bf8ac3f99
|
|||
|
b7efae61c4
|
|||
|
e76cb3dc21
|
230
DOCKER_GUIDE.md
Normal file
230
DOCKER_GUIDE.md
Normal file
@@ -0,0 +1,230 @@
|
|||||||
|
# Docker Guide für Home Automation
|
||||||
|
|
||||||
|
Vollständige Anleitung zum Ausführen aller Services mit Docker/finch.
|
||||||
|
|
||||||
|
## Quick Start - Alle Services starten
|
||||||
|
|
||||||
|
### Linux Server (empfohlen - mit Docker Network)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Images bauen
|
||||||
|
docker build -t api:dev -f apps/api/Dockerfile .
|
||||||
|
docker build -t ui:dev -f apps/ui/Dockerfile .
|
||||||
|
docker build -t abstraction:dev -f apps/abstraction/Dockerfile .
|
||||||
|
docker build -t simulator:dev -f apps/simulator/Dockerfile .
|
||||||
|
|
||||||
|
# 2. Netzwerk erstellen
|
||||||
|
docker network create home-automation
|
||||||
|
|
||||||
|
# 3. Abstraction Layer (MQTT Worker)
|
||||||
|
docker run -d --name abstraction \
|
||||||
|
--network home-automation \
|
||||||
|
-v $(pwd)/config:/app/config:ro \
|
||||||
|
-e MQTT_BROKER=172.16.2.16 \
|
||||||
|
-e REDIS_HOST=172.23.1.116 \
|
||||||
|
-e REDIS_DB=8 \
|
||||||
|
abstraction:dev
|
||||||
|
|
||||||
|
# 4. API Server
|
||||||
|
docker run -d --name api \
|
||||||
|
--network home-automation \
|
||||||
|
-p 8001:8001 \
|
||||||
|
-v $(pwd)/config:/app/config:ro \
|
||||||
|
-e MQTT_BROKER=172.16.2.16 \
|
||||||
|
-e REDIS_HOST=172.23.1.116 \
|
||||||
|
-e REDIS_DB=8 \
|
||||||
|
api:dev
|
||||||
|
|
||||||
|
# 5. Web UI
|
||||||
|
docker run -d --name ui \
|
||||||
|
--network home-automation \
|
||||||
|
-p 8002:8002 \
|
||||||
|
-e API_BASE=http://api:8001 \
|
||||||
|
ui:dev
|
||||||
|
|
||||||
|
# 6. Device Simulator (optional)
|
||||||
|
docker run -d --name simulator \
|
||||||
|
--network home-automation \
|
||||||
|
-p 8010:8010 \
|
||||||
|
-e MQTT_BROKER=172.16.2.16 \
|
||||||
|
simulator:dev
|
||||||
|
```
|
||||||
|
|
||||||
|
### macOS mit finch/nerdctl (Alternative)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Images bauen (wie oben)
|
||||||
|
|
||||||
|
# Abstraction Layer
|
||||||
|
docker run -d --name abstraction \
|
||||||
|
-v $(pwd)/config:/app/config:ro \
|
||||||
|
-e MQTT_BROKER=172.16.2.16 \
|
||||||
|
-e REDIS_HOST=172.23.1.116 \
|
||||||
|
-e REDIS_DB=8 \
|
||||||
|
abstraction:dev
|
||||||
|
|
||||||
|
# API Server
|
||||||
|
docker run -d --name api \
|
||||||
|
-p 8001:8001 \
|
||||||
|
-v $(pwd)/config:/app/config:ro \
|
||||||
|
-e MQTT_BROKER=172.16.2.16 \
|
||||||
|
-e REDIS_HOST=172.23.1.116 \
|
||||||
|
-e REDIS_DB=8 \
|
||||||
|
api:dev
|
||||||
|
|
||||||
|
# Web UI (mit host.docker.internal für macOS)
|
||||||
|
docker run -d --name ui \
|
||||||
|
--add-host=host.docker.internal:host-gateway \
|
||||||
|
-p 8002:8002 \
|
||||||
|
-e API_BASE=http://host.docker.internal:8001 \
|
||||||
|
ui:dev
|
||||||
|
|
||||||
|
# Device Simulator
|
||||||
|
docker run -d --name simulator \
|
||||||
|
-p 8010:8010 \
|
||||||
|
-e MQTT_BROKER=172.16.2.16 \
|
||||||
|
simulator:dev
|
||||||
|
```
|
||||||
|
|
||||||
|
## Zugriff
|
||||||
|
|
||||||
|
- **Web UI**: http://<server-ip>:8002
|
||||||
|
- **API Docs**: http://<server-ip>:8001/docs
|
||||||
|
- **Simulator**: http://<server-ip>:8010
|
||||||
|
|
||||||
|
Auf localhost: `127.0.0.1` oder `localhost`
|
||||||
|
|
||||||
|
## finch/nerdctl Besonderheiten
|
||||||
|
|
||||||
|
### Port-Binding Verhalten (nur macOS/Windows)
|
||||||
|
|
||||||
|
**Standard Docker auf Linux:**
|
||||||
|
- `-p 8001:8001` → bindet auf `0.0.0.0:8001` (von überall erreichbar)
|
||||||
|
|
||||||
|
**finch/nerdctl auf macOS:**
|
||||||
|
- `-p 8001:8001` → bindet auf `127.0.0.1:8001` (nur localhost)
|
||||||
|
- Dies ist ein **Security-Feature** von nerdctl
|
||||||
|
- **Auf Linux-Servern ist das KEIN Problem!**
|
||||||
|
|
||||||
|
### Container-to-Container Kommunikation
|
||||||
|
|
||||||
|
**Linux (empfohlen):**
|
||||||
|
```bash
|
||||||
|
# Docker Network verwenden - Container sprechen sich mit Namen an
|
||||||
|
docker network create home-automation
|
||||||
|
docker run --network home-automation --name api ...
|
||||||
|
docker run --network home-automation -e API_BASE=http://api:8001 ui ...
|
||||||
|
```
|
||||||
|
|
||||||
|
**macOS mit finch:**
|
||||||
|
```bash
|
||||||
|
# host.docker.internal verwenden
|
||||||
|
docker run --add-host=host.docker.internal:host-gateway \
|
||||||
|
-e API_BASE=http://host.docker.internal:8001 ui ...
|
||||||
|
```
|
||||||
|
|
||||||
|
## Container verwalten
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Alle Container anzeigen
|
||||||
|
docker ps
|
||||||
|
|
||||||
|
# Logs anschauen
|
||||||
|
docker logs api
|
||||||
|
docker logs ui -f # Follow mode
|
||||||
|
|
||||||
|
# Container stoppen
|
||||||
|
docker stop api ui abstraction simulator
|
||||||
|
|
||||||
|
# Container entfernen
|
||||||
|
docker rm api ui abstraction simulator
|
||||||
|
|
||||||
|
# Alles neu starten
|
||||||
|
docker stop api ui abstraction simulator && \
|
||||||
|
docker rm api ui abstraction simulator && \
|
||||||
|
# ... dann Quick Start Befehle von oben
|
||||||
|
```
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### UI zeigt "Keine Räume oder Geräte konfiguriert"
|
||||||
|
|
||||||
|
**Problem:** UI kann API nicht erreichen
|
||||||
|
|
||||||
|
**Linux - Lösung:**
|
||||||
|
```bash
|
||||||
|
# Verwende Docker Network
|
||||||
|
docker network create home-automation
|
||||||
|
docker stop ui && docker rm ui
|
||||||
|
docker run -d --name ui \
|
||||||
|
--network home-automation \
|
||||||
|
-p 8002:8002 \
|
||||||
|
-e API_BASE=http://api:8001 \
|
||||||
|
ui:dev
|
||||||
|
```
|
||||||
|
|
||||||
|
**macOS/finch - Lösung:**
|
||||||
|
```bash
|
||||||
|
docker stop ui && docker rm ui
|
||||||
|
docker run -d --name ui \
|
||||||
|
--add-host=host.docker.internal:host-gateway \
|
||||||
|
-p 8002:8002 \
|
||||||
|
-e API_BASE=http://host.docker.internal:8001 \
|
||||||
|
ui:dev
|
||||||
|
```
|
||||||
|
|
||||||
|
### "Connection refused" in Logs
|
||||||
|
|
||||||
|
**Check 1:** Ist die API gestartet?
|
||||||
|
```bash
|
||||||
|
docker ps | grep api
|
||||||
|
curl http://127.0.0.1:8001/health
|
||||||
|
```
|
||||||
|
|
||||||
|
**Check 2:** Hat UI die richtige API_BASE?
|
||||||
|
```bash
|
||||||
|
docker inspect ui | grep API_BASE
|
||||||
|
```
|
||||||
|
|
||||||
|
### Port bereits belegt
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Prüfe welcher Prozess Port 8001 nutzt
|
||||||
|
lsof -i :8001
|
||||||
|
|
||||||
|
# Oder mit netstat
|
||||||
|
netstat -an | grep 8001
|
||||||
|
|
||||||
|
# Alte Container aufräumen
|
||||||
|
docker ps -a | grep -E "api|ui|abstraction|simulator"
|
||||||
|
docker rm -f <container-id>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Produktiv-Deployment
|
||||||
|
|
||||||
|
Für Produktion auf **Linux-Servern** empfohlen:
|
||||||
|
|
||||||
|
1. **Docker Compose** (siehe `infra/docker-compose.yml`)
|
||||||
|
2. **Docker Network** für Service Discovery (siehe Linux Quick Start oben)
|
||||||
|
3. **Volume Mounts** für Persistenz
|
||||||
|
4. **Health Checks** in Kubernetes/Compose (nicht im Dockerfile)
|
||||||
|
|
||||||
|
### Beispiel mit Docker Network (Linux)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Netzwerk erstellen
|
||||||
|
docker network create home-automation
|
||||||
|
|
||||||
|
# Services starten (alle im gleichen Netzwerk)
|
||||||
|
docker run -d --name api --network home-automation \
|
||||||
|
-p 8001:8001 \
|
||||||
|
-v $(pwd)/config:/app/config:ro \
|
||||||
|
api:dev
|
||||||
|
|
||||||
|
docker run -d --name ui --network home-automation \
|
||||||
|
-p 8002:8002 \
|
||||||
|
-e API_BASE=http://api:8001 \
|
||||||
|
ui:dev
|
||||||
|
```
|
||||||
|
|
||||||
|
**Vorteil:** Service Discovery über Container-Namen, keine `--add-host` Tricks nötig.
|
||||||
197
UI_API_CONFIG.md
Normal file
197
UI_API_CONFIG.md
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
# UI API Configuration
|
||||||
|
|
||||||
|
## Übersicht
|
||||||
|
Die UI-Anwendung verwendet keine hart codierten API-URLs mehr. Stattdessen wird die API-Basis-URL über die Umgebungsvariable `API_BASE` konfiguriert.
|
||||||
|
|
||||||
|
## Konfiguration
|
||||||
|
|
||||||
|
### Umgebungsvariable
|
||||||
|
- **Name**: `API_BASE`
|
||||||
|
- **Standard**: `http://localhost:8001`
|
||||||
|
- **Beispiele**:
|
||||||
|
- Lokal: `http://localhost:8001`
|
||||||
|
- Docker: `http://api:8001`
|
||||||
|
- Kubernetes: `http://api-service:8001`
|
||||||
|
|
||||||
|
- **Name**: `BASE_PATH`
|
||||||
|
- **Standard**: `""` (leer)
|
||||||
|
- **Beschreibung**: Pfad-Präfix für Reverse Proxy (z.B. `/ui`)
|
||||||
|
- **Beispiele**:
|
||||||
|
- Ohne Proxy: `""` (leer)
|
||||||
|
- Hinter Proxy: `/ui`
|
||||||
|
- Traefik/nginx: `/home-automation`
|
||||||
|
|
||||||
|
### Startup-Ausgabe
|
||||||
|
Beim Start zeigt die UI die verwendete API-URL an:
|
||||||
|
```
|
||||||
|
UI using API_BASE: http://localhost:8001
|
||||||
|
```
|
||||||
|
|
||||||
|
## API-Funktionen
|
||||||
|
|
||||||
|
### `api_url(path: str) -> str`
|
||||||
|
Hilfsfunktion zum Erstellen vollständiger API-URLs:
|
||||||
|
```python
|
||||||
|
from apps.ui.main import api_url
|
||||||
|
|
||||||
|
# Beispiel
|
||||||
|
url = api_url("/devices") # → "http://localhost:8001/devices"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Health Endpoint
|
||||||
|
Für Kubernetes Liveness/Readiness Probes:
|
||||||
|
```bash
|
||||||
|
GET /health
|
||||||
|
```
|
||||||
|
|
||||||
|
Antwort:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "ok",
|
||||||
|
"service": "ui",
|
||||||
|
"api_base": "http://localhost:8001"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Verwendung
|
||||||
|
|
||||||
|
### Lokal (Entwicklung)
|
||||||
|
```bash
|
||||||
|
# Standard (verwendet http://localhost:8001)
|
||||||
|
poetry run uvicorn apps.ui.main:app --host 0.0.0.0 --port 8002
|
||||||
|
|
||||||
|
# Mit anderer API
|
||||||
|
API_BASE=http://192.168.1.100:8001 poetry run uvicorn apps.ui.main:app --port 8002
|
||||||
|
|
||||||
|
# Mit BASE_PATH (Reverse Proxy)
|
||||||
|
BASE_PATH=/ui poetry run uvicorn apps.ui.main:app --port 8002
|
||||||
|
# Zugriff: http://localhost:8002/ui/
|
||||||
|
```
|
||||||
|
|
||||||
|
### Docker Compose
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
ui:
|
||||||
|
build: .
|
||||||
|
ports:
|
||||||
|
- "8002:8002"
|
||||||
|
environment:
|
||||||
|
- API_BASE=http://api:8001
|
||||||
|
- BASE_PATH="" # Leer für direkten Zugriff
|
||||||
|
depends_on:
|
||||||
|
- api
|
||||||
|
```
|
||||||
|
|
||||||
|
### Docker Compose mit Reverse Proxy
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
nginx:
|
||||||
|
image: nginx:alpine
|
||||||
|
ports:
|
||||||
|
- "80:80"
|
||||||
|
volumes:
|
||||||
|
- ./nginx.conf:/etc/nginx/nginx.conf
|
||||||
|
|
||||||
|
ui:
|
||||||
|
build: .
|
||||||
|
environment:
|
||||||
|
- API_BASE=http://api:8001
|
||||||
|
- BASE_PATH=/ui # Pfad-Präfix für nginx
|
||||||
|
expose:
|
||||||
|
- "8002"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Kubernetes
|
||||||
|
```yaml
|
||||||
|
apiVersion: apps/v1
|
||||||
|
kind: Deployment
|
||||||
|
metadata:
|
||||||
|
name: ui
|
||||||
|
spec:
|
||||||
|
replicas: 2
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
app: ui
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
app: ui
|
||||||
|
spec:
|
||||||
|
containers:
|
||||||
|
- name: ui
|
||||||
|
image: home-automation-ui:latest
|
||||||
|
env:
|
||||||
|
- name: API_BASE
|
||||||
|
value: "http://api-service:8001"
|
||||||
|
- name: BASE_PATH
|
||||||
|
value: "/ui" # Für Ingress
|
||||||
|
ports:
|
||||||
|
- containerPort: 8002
|
||||||
|
livenessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /ui/health # Mit BASE_PATH!
|
||||||
|
port: 8002
|
||||||
|
initialDelaySeconds: 5
|
||||||
|
periodSeconds: 10
|
||||||
|
readinessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /ui/health # Mit BASE_PATH!
|
||||||
|
port: 8002
|
||||||
|
initialDelaySeconds: 5
|
||||||
|
periodSeconds: 5
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
memory: "128Mi"
|
||||||
|
cpu: "100m"
|
||||||
|
limits:
|
||||||
|
memory: "256Mi"
|
||||||
|
cpu: "500m"
|
||||||
|
---
|
||||||
|
apiVersion: networking.k8s.io/v1
|
||||||
|
kind: Ingress
|
||||||
|
metadata:
|
||||||
|
name: ui-ingress
|
||||||
|
spec:
|
||||||
|
rules:
|
||||||
|
- host: home.example.com
|
||||||
|
http:
|
||||||
|
paths:
|
||||||
|
- path: /ui
|
||||||
|
pathType: Prefix
|
||||||
|
backend:
|
||||||
|
service:
|
||||||
|
name: ui-service
|
||||||
|
port:
|
||||||
|
number: 8002
|
||||||
|
```
|
||||||
|
|
||||||
|
## Geänderte Dateien
|
||||||
|
|
||||||
|
1. **apps/ui/main.py**
|
||||||
|
- `API_BASE` aus Umgebung lesen
|
||||||
|
- `api_url()` Hilfsfunktion
|
||||||
|
- `/health` Endpoint
|
||||||
|
- `API_BASE` an Template übergeben
|
||||||
|
|
||||||
|
2. **apps/ui/api_client.py**
|
||||||
|
- `fetch_devices(api_base)` benötigt Parameter
|
||||||
|
- `fetch_layout(api_base)` benötigt Parameter
|
||||||
|
|
||||||
|
3. **apps/ui/templates/dashboard.html**
|
||||||
|
- JavaScript verwendet `{{ api_base }}` aus Backend
|
||||||
|
|
||||||
|
## Akzeptanz-Kriterien ✓
|
||||||
|
|
||||||
|
- ✅ `print(API_BASE)` zeigt korrekten Wert beim Start
|
||||||
|
- ✅ UI funktioniert lokal ohne Codeänderung
|
||||||
|
- ✅ Mit `API_BASE=http://api:8001` ruft UI korrekt den API-Service an
|
||||||
|
- ✅ Health-Endpoint für Kubernetes verfügbar
|
||||||
|
- ✅ Keine hart codierten URLs mehr
|
||||||
|
|
||||||
|
## Vorteile
|
||||||
|
|
||||||
|
1. **Flexibilität**: API-URL per ENV konfigurierbar
|
||||||
|
2. **Docker/K8s Ready**: Service Discovery unterstützt
|
||||||
|
3. **Health Checks**: Monitoring-Integration möglich
|
||||||
|
4. **Abwärtskompatibel**: Bestehende Deployments funktionieren weiter
|
||||||
|
5. **Clean Code**: Zentrale Konfiguration statt verteilte Hardcodes
|
||||||
44
apps/abstraction/Dockerfile
Normal file
44
apps/abstraction/Dockerfile
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
# Abstraction Layer Dockerfile
|
||||||
|
# MQTT ↔ Device Protocol Translation Worker
|
||||||
|
|
||||||
|
FROM python:3.14-alpine
|
||||||
|
|
||||||
|
# Prevent Python from writing .pyc files and enable unbuffered output
|
||||||
|
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||||
|
PYTHONUNBUFFERED=1 \
|
||||||
|
MQTT_BROKER=172.16.2.16 \
|
||||||
|
MQTT_PORT=1883 \
|
||||||
|
REDIS_HOST=localhost \
|
||||||
|
REDIS_PORT=6379 \
|
||||||
|
REDIS_DB=0
|
||||||
|
|
||||||
|
# Create non-root user
|
||||||
|
RUN addgroup -g 10001 -S app && \
|
||||||
|
adduser -u 10001 -S app -G app
|
||||||
|
|
||||||
|
# Set working directory
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install system dependencies
|
||||||
|
RUN apk add --no-cache \
|
||||||
|
gcc \
|
||||||
|
musl-dev \
|
||||||
|
linux-headers
|
||||||
|
|
||||||
|
# Install Python dependencies
|
||||||
|
COPY apps/abstraction/requirements.txt /app/requirements.txt
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
# Copy application code
|
||||||
|
COPY apps/__init__.py /app/apps/__init__.py
|
||||||
|
COPY apps/abstraction/ /app/apps/abstraction/
|
||||||
|
COPY packages/ /app/packages/
|
||||||
|
|
||||||
|
# Change ownership to app user
|
||||||
|
RUN chown -R app:app /app
|
||||||
|
|
||||||
|
# Switch to non-root user
|
||||||
|
USER app
|
||||||
|
|
||||||
|
# Run application
|
||||||
|
CMD ["python", "-m", "apps.abstraction.main"]
|
||||||
@@ -12,11 +12,46 @@ The abstraction layer is an asyncio-based worker that manages device communicati
|
|||||||
|
|
||||||
## Running
|
## Running
|
||||||
|
|
||||||
|
### Local Development
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Start the abstraction worker
|
# Start the abstraction worker
|
||||||
poetry run python -m apps.abstraction.main
|
poetry run python -m apps.abstraction.main
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Docker Container
|
||||||
|
|
||||||
|
#### Build Image
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker build -t abstraction:dev -f apps/abstraction/Dockerfile .
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Run Container
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run --rm \
|
||||||
|
-v $(pwd)/config:/app/config:ro \
|
||||||
|
-e MQTT_BROKER=172.23.1.102 \
|
||||||
|
-e MQTT_PORT=1883 \
|
||||||
|
-e REDIS_HOST=172.23.1.116 \
|
||||||
|
-e REDIS_PORT=6379 \
|
||||||
|
-e REDIS_DB=8 \
|
||||||
|
abstraction:dev
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Environment Variables
|
||||||
|
|
||||||
|
| Variable | Default | Description |
|
||||||
|
|----------|---------|-------------|
|
||||||
|
| `MQTT_BROKER` | `172.16.2.16` | MQTT broker hostname/IP |
|
||||||
|
| `MQTT_PORT` | `1883` | MQTT broker port |
|
||||||
|
| `REDIS_HOST` | `localhost` | Redis server hostname/IP |
|
||||||
|
| `REDIS_PORT` | `6379` | Redis server port |
|
||||||
|
| `REDIS_DB` | `0` | Redis database number |
|
||||||
|
|
||||||
|
### What the Worker Does
|
||||||
|
|
||||||
The worker will:
|
The worker will:
|
||||||
1. Load configuration from `config/devices.yaml`
|
1. Load configuration from `config/devices.yaml`
|
||||||
2. Connect to MQTT broker (172.16.2.16:1883)
|
2. Connect to MQTT broker (172.16.2.16:1883)
|
||||||
|
|||||||
@@ -38,8 +38,8 @@ def load_config(config_path: Path) -> dict[str, Any]:
|
|||||||
logger.warning(f"Config file not found: {config_path}, using defaults")
|
logger.warning(f"Config file not found: {config_path}, using defaults")
|
||||||
return {
|
return {
|
||||||
"mqtt": {
|
"mqtt": {
|
||||||
"broker": "172.16.2.16",
|
"broker": os.getenv("MQTT_BROKER", "localhost"),
|
||||||
"port": 1883,
|
"port": int(os.getenv("MQTT_PORT", "1883")),
|
||||||
"client_id": "home-automation-abstraction",
|
"client_id": "home-automation-abstraction",
|
||||||
"keepalive": 60
|
"keepalive": 60
|
||||||
},
|
},
|
||||||
@@ -227,8 +227,8 @@ async def mqtt_worker(config: dict[str, Any], redis_client: aioredis.Redis) -> N
|
|||||||
redis_client: Redis client for UI updates
|
redis_client: Redis client for UI updates
|
||||||
"""
|
"""
|
||||||
mqtt_config = config.get("mqtt", {})
|
mqtt_config = config.get("mqtt", {})
|
||||||
broker = mqtt_config.get("broker", "172.16.2.16")
|
broker = os.getenv("MQTT_BROKER") or mqtt_config.get("broker", "localhost")
|
||||||
port = mqtt_config.get("port", 1883)
|
port = int(os.getenv("MQTT_PORT", mqtt_config.get("port", 1883)))
|
||||||
client_id = mqtt_config.get("client_id", "home-automation-abstraction")
|
client_id = mqtt_config.get("client_id", "home-automation-abstraction")
|
||||||
# Append a short suffix (ENV override possible) so multiple processes don't collide
|
# Append a short suffix (ENV override possible) so multiple processes don't collide
|
||||||
client_suffix = os.environ.get("MQTT_CLIENT_ID_SUFFIX") or uuid.uuid4().hex[:6]
|
client_suffix = os.environ.get("MQTT_CLIENT_ID_SUFFIX") or uuid.uuid4().hex[:6]
|
||||||
|
|||||||
5
apps/abstraction/requirements.txt
Normal file
5
apps/abstraction/requirements.txt
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
pydantic>=2
|
||||||
|
aiomqtt==2.0.1
|
||||||
|
redis==5.0.1
|
||||||
|
pyyaml==6.0.1
|
||||||
|
tenacity==8.2.3
|
||||||
48
apps/api/Dockerfile
Normal file
48
apps/api/Dockerfile
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
# API Service Dockerfile
|
||||||
|
# FastAPI + Redis + MQTT Gateway
|
||||||
|
|
||||||
|
FROM python:3.14-alpine
|
||||||
|
|
||||||
|
# Prevent Python from writing .pyc files and enable unbuffered output
|
||||||
|
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||||
|
PYTHONUNBUFFERED=1 \
|
||||||
|
MQTT_BROKER=172.16.2.16 \
|
||||||
|
MQTT_PORT=1883 \
|
||||||
|
REDIS_HOST=localhost \
|
||||||
|
REDIS_PORT=6379 \
|
||||||
|
REDIS_DB=0 \
|
||||||
|
REDIS_CHANNEL=ui:updates
|
||||||
|
|
||||||
|
# Create non-root user
|
||||||
|
RUN addgroup -g 10001 -S app && \
|
||||||
|
adduser -u 10001 -S app -G app
|
||||||
|
|
||||||
|
# Set working directory
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install system dependencies
|
||||||
|
RUN apk add --no-cache \
|
||||||
|
gcc \
|
||||||
|
musl-dev \
|
||||||
|
linux-headers
|
||||||
|
|
||||||
|
# Install Python dependencies
|
||||||
|
COPY apps/api/requirements.txt /app/requirements.txt
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
# Copy application code
|
||||||
|
COPY apps/__init__.py /app/apps/__init__.py
|
||||||
|
COPY apps/api/ /app/apps/api/
|
||||||
|
COPY packages/ /app/packages/
|
||||||
|
|
||||||
|
# Change ownership to app user
|
||||||
|
RUN chown -R app:app /app
|
||||||
|
|
||||||
|
# Switch to non-root user
|
||||||
|
USER app
|
||||||
|
|
||||||
|
# Expose port
|
||||||
|
EXPOSE 8001
|
||||||
|
|
||||||
|
# Run application
|
||||||
|
CMD ["python", "-m", "uvicorn", "apps.api.main:app", "--host", "0.0.0.0", "--port", "8001"]
|
||||||
@@ -20,7 +20,7 @@ poetry run uvicorn apps.api.main:app --reload
|
|||||||
### Production Mode
|
### Production Mode
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
poetry run uvicorn apps.api.main:app --host 0.0.0.0 --port 8000
|
poetry run uvicorn apps.api.main:app --host 0.0.0.0 --port 8001
|
||||||
```
|
```
|
||||||
|
|
||||||
### Using Python directly
|
### Using Python directly
|
||||||
@@ -29,6 +29,56 @@ poetry run uvicorn apps.api.main:app --host 0.0.0.0 --port 8000
|
|||||||
poetry run python -m apps.api.main
|
poetry run python -m apps.api.main
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Docker Container
|
||||||
|
|
||||||
|
#### Build Image
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker build -t api:dev -f apps/api/Dockerfile .
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Run Container
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run --rm -p 8001:8001 \
|
||||||
|
-v $(pwd)/config:/app/config:ro \
|
||||||
|
-e MQTT_BROKER=172.23.1.102 \
|
||||||
|
-e MQTT_PORT=1883 \
|
||||||
|
-e REDIS_HOST=172.23.1.116 \
|
||||||
|
-e REDIS_PORT=6379 \
|
||||||
|
-e REDIS_DB=8 \
|
||||||
|
-e REDIS_CHANNEL=ui:updates \
|
||||||
|
api:dev
|
||||||
|
```
|
||||||
|
|
||||||
|
**Mit Docker Network (empfohlen für Linux):**
|
||||||
|
```bash
|
||||||
|
docker network create home-automation
|
||||||
|
docker run --rm -p 8001:8001 \
|
||||||
|
--network home-automation \
|
||||||
|
--name api \
|
||||||
|
-v $(pwd)/config:/app/config:ro \
|
||||||
|
-e MQTT_BROKER=172.16.2.16 \
|
||||||
|
-e REDIS_HOST=172.23.1.116 \
|
||||||
|
-e REDIS_DB=8 \
|
||||||
|
api:dev
|
||||||
|
```
|
||||||
|
|
||||||
|
**Hinweise:**
|
||||||
|
- **Linux**: Port wird auf `0.0.0.0:8001` gebunden (von überall erreichbar)
|
||||||
|
- **macOS/finch**: Port wird auf `127.0.0.1:8001` gebunden (nur localhost)
|
||||||
|
|
||||||
|
#### Environment Variables
|
||||||
|
|
||||||
|
| Variable | Default | Description |
|
||||||
|
|----------|---------|-------------|
|
||||||
|
| `MQTT_BROKER` | `172.16.2.16` | MQTT broker hostname/IP |
|
||||||
|
| `MQTT_PORT` | `1883` | MQTT broker port |
|
||||||
|
| `REDIS_HOST` | `localhost` | Redis server hostname/IP |
|
||||||
|
| `REDIS_PORT` | `6379` | Redis server port |
|
||||||
|
| `REDIS_DB` | `0` | Redis database number |
|
||||||
|
| `REDIS_CHANNEL` | `ui:updates` | Redis pub/sub channel |
|
||||||
|
|
||||||
## API Endpoints
|
## API Endpoints
|
||||||
|
|
||||||
### `GET /health`
|
### `GET /health`
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ app.add_middleware(
|
|||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
allow_origins=[
|
allow_origins=[
|
||||||
"http://localhost:8002",
|
"http://localhost:8002",
|
||||||
|
"http://172.19.1.11:8002",
|
||||||
"http://127.0.0.1:8002",
|
"http://127.0.0.1:8002",
|
||||||
],
|
],
|
||||||
allow_credentials=True,
|
allow_credentials=True,
|
||||||
@@ -104,10 +105,12 @@ def load_devices() -> list[dict[str, Any]]:
|
|||||||
def get_mqtt_settings() -> tuple[str, int]:
|
def get_mqtt_settings() -> tuple[str, int]:
|
||||||
"""Get MQTT broker settings from environment.
|
"""Get MQTT broker settings from environment.
|
||||||
|
|
||||||
|
Supports both MQTT_BROKER and MQTT_HOST for compatibility.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
tuple: (host, port)
|
tuple: (host, port)
|
||||||
"""
|
"""
|
||||||
host = os.environ.get("MQTT_HOST", "172.16.2.16")
|
host = os.environ.get("MQTT_BROKER") or os.environ.get("MQTT_HOST", "172.16.2.16")
|
||||||
port = int(os.environ.get("MQTT_PORT", "1883"))
|
port = int(os.environ.get("MQTT_PORT", "1883"))
|
||||||
return host, port
|
return host, port
|
||||||
|
|
||||||
@@ -115,9 +118,24 @@ def get_mqtt_settings() -> tuple[str, int]:
|
|||||||
def get_redis_settings() -> tuple[str, str]:
|
def get_redis_settings() -> tuple[str, str]:
|
||||||
"""Get Redis settings from configuration.
|
"""Get Redis settings from configuration.
|
||||||
|
|
||||||
|
Prioritizes environment variables over config file:
|
||||||
|
- REDIS_HOST, REDIS_PORT, REDIS_DB → redis://host:port/db
|
||||||
|
- REDIS_CHANNEL → pub/sub channel name
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
tuple: (url, channel)
|
tuple: (url, channel)
|
||||||
"""
|
"""
|
||||||
|
# Check environment variables first
|
||||||
|
redis_host = os.getenv("REDIS_HOST")
|
||||||
|
redis_port = os.getenv("REDIS_PORT", "6379")
|
||||||
|
redis_db = os.getenv("REDIS_DB", "0")
|
||||||
|
redis_channel = os.getenv("REDIS_CHANNEL", "ui:updates")
|
||||||
|
|
||||||
|
if redis_host:
|
||||||
|
url = f"redis://{redis_host}:{redis_port}/{redis_db}"
|
||||||
|
return url, redis_channel
|
||||||
|
|
||||||
|
# Fallback to config file
|
||||||
config_path = Path(__file__).parent.parent.parent / "config" / "devices.yaml"
|
config_path = Path(__file__).parent.parent.parent / "config" / "devices.yaml"
|
||||||
|
|
||||||
if config_path.exists():
|
if config_path.exists():
|
||||||
@@ -283,41 +301,42 @@ async def event_generator(request: Request) -> AsyncGenerator[str, None]:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
await pubsub.subscribe(redis_channel)
|
await pubsub.subscribe(redis_channel)
|
||||||
|
logger.info(f"SSE client connected, subscribed to {redis_channel}")
|
||||||
|
|
||||||
# Create heartbeat task
|
# Create heartbeat tracking
|
||||||
last_heartbeat = asyncio.get_event_loop().time()
|
last_heartbeat = asyncio.get_event_loop().time()
|
||||||
|
heartbeat_interval = 25
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
# Check if client disconnected
|
# Check if client disconnected
|
||||||
if await request.is_disconnected():
|
if await request.is_disconnected():
|
||||||
|
logger.info("SSE client disconnected")
|
||||||
break
|
break
|
||||||
|
|
||||||
# Get message with timeout for heartbeat
|
# Try to get message (non-blocking)
|
||||||
try:
|
message = await pubsub.get_message(ignore_subscribe_messages=True, timeout=0.1)
|
||||||
message = await asyncio.wait_for(
|
|
||||||
pubsub.get_message(ignore_subscribe_messages=True),
|
# Handle actual data messages
|
||||||
timeout=1.0
|
if message and message["type"] == "message":
|
||||||
)
|
data = message["data"]
|
||||||
|
logger.debug(f"Sending SSE message: {data[:100]}...")
|
||||||
if message and message["type"] == "message":
|
yield f"event: message\ndata: {data}\n\n"
|
||||||
# Send data event
|
last_heartbeat = asyncio.get_event_loop().time()
|
||||||
data = message["data"]
|
else:
|
||||||
yield f"event: message\ndata: {data}\n\n"
|
# No message, sleep a bit to avoid busy loop
|
||||||
last_heartbeat = asyncio.get_event_loop().time()
|
await asyncio.sleep(0.1)
|
||||||
|
|
||||||
except asyncio.TimeoutError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Send heartbeat every 25 seconds
|
# Send heartbeat every 25 seconds
|
||||||
current_time = asyncio.get_event_loop().time()
|
current_time = asyncio.get_event_loop().time()
|
||||||
if current_time - last_heartbeat >= 25:
|
if current_time - last_heartbeat >= heartbeat_interval:
|
||||||
yield "event: ping\ndata: heartbeat\n\n"
|
yield "event: ping\ndata: heartbeat\n\n"
|
||||||
last_heartbeat = current_time
|
last_heartbeat = current_time
|
||||||
|
|
||||||
finally:
|
finally:
|
||||||
await pubsub.unsubscribe(redis_channel)
|
await pubsub.unsubscribe(redis_channel)
|
||||||
await pubsub.close()
|
await pubsub.aclose()
|
||||||
await redis_client.close()
|
await redis_client.aclose()
|
||||||
|
logger.info("SSE connection closed")
|
||||||
|
|
||||||
|
|
||||||
@app.get("/realtime")
|
@app.get("/realtime")
|
||||||
|
|||||||
6
apps/api/requirements.txt
Normal file
6
apps/api/requirements.txt
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
fastapi==0.104.1
|
||||||
|
uvicorn[standard]==0.24.0
|
||||||
|
pydantic>=2
|
||||||
|
redis==5.0.1
|
||||||
|
aiomqtt==2.0.1
|
||||||
|
pyyaml==6.0.1
|
||||||
44
apps/simulator/Dockerfile
Normal file
44
apps/simulator/Dockerfile
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
# Simulator Service Dockerfile
|
||||||
|
# FastAPI Web UI + MQTT Device Simulator
|
||||||
|
|
||||||
|
FROM python:3.14-alpine
|
||||||
|
|
||||||
|
# Prevent Python from writing .pyc files and enable unbuffered output
|
||||||
|
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||||
|
PYTHONUNBUFFERED=1 \
|
||||||
|
MQTT_BROKER=172.16.2.16 \
|
||||||
|
MQTT_PORT=1883 \
|
||||||
|
SIM_PORT=8010
|
||||||
|
|
||||||
|
# Create non-root user
|
||||||
|
RUN addgroup -g 10001 -S app && \
|
||||||
|
adduser -u 10001 -S app -G app
|
||||||
|
|
||||||
|
# Set working directory
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install system dependencies
|
||||||
|
RUN apk add --no-cache \
|
||||||
|
gcc \
|
||||||
|
musl-dev \
|
||||||
|
linux-headers
|
||||||
|
|
||||||
|
# Install Python dependencies
|
||||||
|
COPY apps/simulator/requirements.txt /app/requirements.txt
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
# Copy application code
|
||||||
|
COPY apps/__init__.py /app/apps/__init__.py
|
||||||
|
COPY apps/simulator/ /app/apps/simulator/
|
||||||
|
|
||||||
|
# Change ownership to app user
|
||||||
|
RUN chown -R app:app /app
|
||||||
|
|
||||||
|
# Switch to non-root user
|
||||||
|
USER app
|
||||||
|
|
||||||
|
# Expose port
|
||||||
|
EXPOSE 8010
|
||||||
|
|
||||||
|
# Run the simulator
|
||||||
|
CMD ["python", "-m", "uvicorn", "apps.simulator.main:app", "--host", "0.0.0.0", "--port", "8010"]
|
||||||
@@ -28,22 +28,58 @@ Der Simulator ist bereits Teil des Projekts. Keine zusätzlichen Dependencies er
|
|||||||
|
|
||||||
## Start
|
## Start
|
||||||
|
|
||||||
|
### Local Development
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Standard-Start (Port 8003)
|
# Standard-Start (Port 8010)
|
||||||
poetry run uvicorn apps.simulator.main:app --host 0.0.0.0 --port 8003
|
poetry run uvicorn apps.simulator.main:app --host 0.0.0.0 --port 8010
|
||||||
|
|
||||||
# Mit Auto-Reload für Entwicklung
|
# Mit Auto-Reload für Entwicklung
|
||||||
poetry run uvicorn apps.simulator.main:app --host 0.0.0.0 --port 8003 --reload
|
poetry run uvicorn apps.simulator.main:app --host 0.0.0.0 --port 8010 --reload
|
||||||
|
|
||||||
# Im Hintergrund
|
# Im Hintergrund
|
||||||
poetry run uvicorn apps.simulator.main:app --host 0.0.0.0 --port 8003 > /tmp/simulator.log 2>&1 &
|
poetry run uvicorn apps.simulator.main:app --host 0.0.0.0 --port 8010 > /tmp/simulator.log 2>&1 &
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Docker Container
|
||||||
|
|
||||||
|
#### Build Image
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker build -t simulator:dev -f apps/simulator/Dockerfile .
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Run Container
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run --rm -p 8010:8010 \
|
||||||
|
-e MQTT_BROKER=172.23.1.102 \
|
||||||
|
-e MQTT_PORT=1883 \
|
||||||
|
-e SIM_PORT=8010 \
|
||||||
|
simulator:dev
|
||||||
|
```
|
||||||
|
|
||||||
|
**Mit Docker Network (optional):**
|
||||||
|
```bash
|
||||||
|
docker run --rm -p 8010:8010 \
|
||||||
|
--name simulator \
|
||||||
|
-e MQTT_BROKER=172.23.1.102 \
|
||||||
|
simulator:dev
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Environment Variables
|
||||||
|
|
||||||
|
| Variable | Default | Description |
|
||||||
|
|----------|---------|-------------|
|
||||||
|
| `MQTT_BROKER` | `172.16.2.16` | MQTT broker hostname/IP |
|
||||||
|
| `MQTT_PORT` | `1883` | MQTT broker port |
|
||||||
|
| `SIM_PORT` | `8010` | Port for web interface |
|
||||||
|
|
||||||
## Web-Interface
|
## Web-Interface
|
||||||
|
|
||||||
Öffne im Browser:
|
Öffne im Browser:
|
||||||
```
|
```
|
||||||
http://localhost:8003
|
http://localhost:8010
|
||||||
```
|
```
|
||||||
|
|
||||||
### Features im Dashboard
|
### Features im Dashboard
|
||||||
|
|||||||
4
apps/simulator/requirements.txt
Normal file
4
apps/simulator/requirements.txt
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
fastapi==0.104.1
|
||||||
|
uvicorn[standard]==0.24.0
|
||||||
|
aiomqtt==2.0.1
|
||||||
|
jinja2==3.1.2
|
||||||
49
apps/ui/Dockerfile
Normal file
49
apps/ui/Dockerfile
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
# UI Service Dockerfile
|
||||||
|
# FastAPI + Jinja2 + HTMX Dashboard
|
||||||
|
|
||||||
|
FROM python:3.14-alpine
|
||||||
|
|
||||||
|
# Prevent Python from writing .pyc files and enable unbuffered output
|
||||||
|
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||||
|
PYTHONUNBUFFERED=1 \
|
||||||
|
UI_PORT=8002 \
|
||||||
|
API_BASE=http://api:8001 \
|
||||||
|
BASE_PATH=""
|
||||||
|
|
||||||
|
# Create non-root user
|
||||||
|
RUN addgroup -g 10001 -S app && \
|
||||||
|
adduser -u 10001 -S app -G app
|
||||||
|
|
||||||
|
# Set working directory
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install system dependencies
|
||||||
|
RUN apk add --no-cache \
|
||||||
|
curl \
|
||||||
|
gcc \
|
||||||
|
musl-dev \
|
||||||
|
linux-headers
|
||||||
|
|
||||||
|
# Install Python dependencies
|
||||||
|
COPY apps/ui/requirements.txt /app/requirements.txt
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
# Copy application code
|
||||||
|
COPY apps/__init__.py /app/apps/__init__.py
|
||||||
|
COPY apps/ui/ /app/apps/ui/
|
||||||
|
|
||||||
|
# Change ownership to app user
|
||||||
|
RUN chown -R app:app /app
|
||||||
|
|
||||||
|
# Switch to non-root user
|
||||||
|
USER app
|
||||||
|
|
||||||
|
# Health check
|
||||||
|
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||||
|
CMD curl -f http://localhost:${UI_PORT}/health || exit 1
|
||||||
|
|
||||||
|
# Expose port
|
||||||
|
EXPOSE 8002
|
||||||
|
|
||||||
|
# Run application
|
||||||
|
CMD ["python", "-m", "uvicorn", "apps.ui.main:app", "--host", "0.0.0.0", "--port", "8002"]
|
||||||
@@ -27,6 +27,50 @@ poetry run uvicorn apps.ui.main:app --reload --port 8002
|
|||||||
poetry run python -m apps.ui.main
|
poetry run python -m apps.ui.main
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Docker Container
|
||||||
|
|
||||||
|
#### Build Image
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker build -t ui:dev -f apps/ui/Dockerfile .
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Run Container
|
||||||
|
|
||||||
|
**Linux Server (empfohlen):**
|
||||||
|
```bash
|
||||||
|
# Mit Docker Network für Container-to-Container Kommunikation
|
||||||
|
docker run --rm -p 8002:8002 \
|
||||||
|
-e UI_PORT=8002 \
|
||||||
|
-e API_BASE=http://172.19.1.11:8001 \
|
||||||
|
-e BASE_PATH=/ \
|
||||||
|
ui:dev
|
||||||
|
```
|
||||||
|
|
||||||
|
**macOS mit finch/nerdctl:**
|
||||||
|
```bash
|
||||||
|
docker run --rm -p 8002:8002 \
|
||||||
|
--add-host=host.docker.internal:host-gateway \
|
||||||
|
-e UI_PORT=8002 \
|
||||||
|
-e API_BASE=http://host.docker.internal:8001 \
|
||||||
|
-e BASE_PATH=/ \
|
||||||
|
ui:dev
|
||||||
|
```
|
||||||
|
|
||||||
|
**Hinweise:**
|
||||||
|
- **Linux**: Verwende Docker Network und Service-Namen (`http://api:8001`)
|
||||||
|
- **macOS/finch**: Verwende `host.docker.internal` mit `--add-host` flag
|
||||||
|
- Die UI macht Server-Side API-Aufrufe beim Rendern der Seite
|
||||||
|
- Browser-seitige Realtime-Updates (SSE) gehen direkt vom Browser zur API
|
||||||
|
|
||||||
|
#### Environment Variables
|
||||||
|
|
||||||
|
| Variable | Default | Description |
|
||||||
|
|----------|---------|-------------|
|
||||||
|
| `UI_PORT` | `8002` | Port for UI server |
|
||||||
|
| `API_BASE` | `http://localhost:8001` | Base URL for API service |
|
||||||
|
| `BASE_PATH` | `/` | Base path for routing |
|
||||||
|
|
||||||
## Project Structure
|
## Project Structure
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|||||||
171
apps/ui/README_DOCKER.md
Normal file
171
apps/ui/README_DOCKER.md
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
# UI Service - Docker
|
||||||
|
|
||||||
|
FastAPI + Jinja2 + HTMX Dashboard für Home Automation
|
||||||
|
|
||||||
|
## Build
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker build -t ui:dev -f apps/ui/Dockerfile .
|
||||||
|
```
|
||||||
|
|
||||||
|
## Run
|
||||||
|
|
||||||
|
### Lokal
|
||||||
|
```bash
|
||||||
|
docker run --rm -p 8002:8002 -e API_BASE=http://localhost:8001 ui:dev
|
||||||
|
```
|
||||||
|
|
||||||
|
### Docker Compose
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
ui:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: apps/ui/Dockerfile
|
||||||
|
ports:
|
||||||
|
- "8002:8002"
|
||||||
|
environment:
|
||||||
|
- API_BASE=http://api:8001
|
||||||
|
depends_on:
|
||||||
|
- api
|
||||||
|
```
|
||||||
|
|
||||||
|
### Kubernetes
|
||||||
|
```yaml
|
||||||
|
apiVersion: apps/v1
|
||||||
|
kind: Deployment
|
||||||
|
metadata:
|
||||||
|
name: ui
|
||||||
|
spec:
|
||||||
|
replicas: 2
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
app: ui
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
app: ui
|
||||||
|
spec:
|
||||||
|
containers:
|
||||||
|
- name: ui
|
||||||
|
image: ui:dev
|
||||||
|
ports:
|
||||||
|
- containerPort: 8002
|
||||||
|
env:
|
||||||
|
- name: API_BASE
|
||||||
|
value: "http://api-service:8001"
|
||||||
|
livenessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /health
|
||||||
|
port: 8002
|
||||||
|
initialDelaySeconds: 5
|
||||||
|
periodSeconds: 10
|
||||||
|
readinessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /health
|
||||||
|
port: 8002
|
||||||
|
initialDelaySeconds: 3
|
||||||
|
periodSeconds: 5
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
memory: "128Mi"
|
||||||
|
cpu: "100m"
|
||||||
|
limits:
|
||||||
|
memory: "256Mi"
|
||||||
|
cpu: "500m"
|
||||||
|
---
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Service
|
||||||
|
metadata:
|
||||||
|
name: ui-service
|
||||||
|
spec:
|
||||||
|
selector:
|
||||||
|
app: ui
|
||||||
|
ports:
|
||||||
|
- protocol: TCP
|
||||||
|
port: 8002
|
||||||
|
targetPort: 8002
|
||||||
|
type: LoadBalancer
|
||||||
|
```
|
||||||
|
|
||||||
|
## Umgebungsvariablen
|
||||||
|
|
||||||
|
| Variable | Default | Beschreibung |
|
||||||
|
|----------|---------|--------------|
|
||||||
|
| `API_BASE` | `http://api:8001` | URL des API-Services |
|
||||||
|
| `UI_PORT` | `8002` | Port der UI-Anwendung |
|
||||||
|
| `PYTHONDONTWRITEBYTECODE` | `1` | Keine .pyc Files |
|
||||||
|
| `PYTHONUNBUFFERED` | `1` | Unbuffered Output |
|
||||||
|
|
||||||
|
## Endpoints
|
||||||
|
|
||||||
|
- `GET /` - Dashboard
|
||||||
|
- `GET /health` - Health Check
|
||||||
|
- `GET /dashboard` - Dashboard (alias)
|
||||||
|
|
||||||
|
## Security
|
||||||
|
|
||||||
|
- Container läuft als **non-root** User `app` (UID: 10001)
|
||||||
|
- Minimales Python 3.11-slim Base Image
|
||||||
|
- Keine unnötigen System-Pakete
|
||||||
|
- Health Check integriert
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- ✅ FastAPI Backend
|
||||||
|
- ✅ Jinja2 Templates
|
||||||
|
- ✅ HTMX für reactive UI
|
||||||
|
- ✅ Server-Sent Events (SSE)
|
||||||
|
- ✅ Responsive Design
|
||||||
|
- ✅ Docker & Kubernetes ready
|
||||||
|
- ✅ Health Check Endpoint
|
||||||
|
- ✅ Non-root Container
|
||||||
|
- ✅ Configurable API Backend
|
||||||
|
|
||||||
|
## Entwicklung
|
||||||
|
|
||||||
|
### Lokales Testing
|
||||||
|
```bash
|
||||||
|
# Build
|
||||||
|
docker build -t ui:dev -f apps/ui/Dockerfile .
|
||||||
|
|
||||||
|
# Run
|
||||||
|
docker run -d --name ui-test -p 8002:8002 -e API_BASE=http://localhost:8001 ui:dev
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
docker logs -f ui-test
|
||||||
|
|
||||||
|
# Health Check
|
||||||
|
curl http://localhost:8002/health
|
||||||
|
|
||||||
|
# Cleanup
|
||||||
|
docker stop ui-test && docker rm ui-test
|
||||||
|
```
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
```bash
|
||||||
|
bash /tmp/test_ui_dockerfile.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Container startet nicht
|
||||||
|
```bash
|
||||||
|
docker logs ui-test
|
||||||
|
```
|
||||||
|
|
||||||
|
### Health Check schlägt fehl
|
||||||
|
```bash
|
||||||
|
docker exec ui-test curl http://localhost:8002/health
|
||||||
|
```
|
||||||
|
|
||||||
|
### API_BASE nicht korrekt
|
||||||
|
```bash
|
||||||
|
docker logs ui-test 2>&1 | grep "UI using API_BASE"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Non-root Verifizieren
|
||||||
|
```bash
|
||||||
|
docker exec ui-test id
|
||||||
|
# Sollte zeigen: uid=10001(app) gid=10001(app)
|
||||||
|
```
|
||||||
@@ -8,12 +8,12 @@ import httpx
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def fetch_devices(api_base: str = "http://localhost:8001") -> list[dict]:
|
def fetch_devices(api_base: str) -> list[dict]:
|
||||||
"""
|
"""
|
||||||
Fetch devices from the API Gateway.
|
Fetch devices from the API Gateway.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
api_base: Base URL of the API Gateway (default: http://localhost:8001)
|
api_base: Base URL of the API Gateway (e.g., "http://localhost:8001" or "http://api:8001")
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
List of device dictionaries. Each device contains at least:
|
List of device dictionaries. Each device contains at least:
|
||||||
@@ -56,12 +56,12 @@ def fetch_devices(api_base: str = "http://localhost:8001") -> list[dict]:
|
|||||||
return []
|
return []
|
||||||
|
|
||||||
|
|
||||||
def fetch_layout(api_base: str = "http://localhost:8001") -> dict:
|
def fetch_layout(api_base: str) -> dict:
|
||||||
"""
|
"""
|
||||||
Fetch UI layout from the API Gateway.
|
Fetch UI layout from the API Gateway.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
api_base: Base URL of the API Gateway (default: http://localhost:8001)
|
api_base: Base URL of the API Gateway (e.g., "http://localhost:8001" or "http://api:8001")
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Layout dictionary with structure:
|
Layout dictionary with structure:
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
"""UI main entry point."""
|
"""UI main entry point."""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from fastapi import FastAPI, Request
|
from fastapi import FastAPI, Request
|
||||||
from fastapi.responses import HTMLResponse
|
from fastapi.responses import HTMLResponse, JSONResponse
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
from fastapi.templating import Jinja2Templates
|
from fastapi.templating import Jinja2Templates
|
||||||
|
|
||||||
@@ -12,11 +13,30 @@ from apps.ui.api_client import fetch_devices, fetch_layout
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# Initialize FastAPI app
|
# Read configuration from environment variables
|
||||||
|
API_BASE = os.getenv("API_BASE", "http://localhost:8001")
|
||||||
|
BASE_PATH = os.getenv("BASE_PATH", "") # e.g., "/ui" for reverse proxy
|
||||||
|
|
||||||
|
print(f"UI using API_BASE: {API_BASE}")
|
||||||
|
print(f"UI using BASE_PATH: {BASE_PATH}")
|
||||||
|
|
||||||
|
def api_url(path: str) -> str:
|
||||||
|
"""Helper function to construct API URLs.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
path: API path (e.g., "/devices")
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Full API URL
|
||||||
|
"""
|
||||||
|
return f"{API_BASE}{path}"
|
||||||
|
|
||||||
|
# Initialize FastAPI app with optional root_path for reverse proxy
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title="Home Automation UI",
|
title="Home Automation UI",
|
||||||
description="User interface for home automation system",
|
description="User interface for home automation system",
|
||||||
version="0.1.0"
|
version="0.1.0",
|
||||||
|
root_path=BASE_PATH
|
||||||
)
|
)
|
||||||
|
|
||||||
# Setup Jinja2 templates
|
# Setup Jinja2 templates
|
||||||
@@ -29,6 +49,21 @@ static_dir.mkdir(exist_ok=True)
|
|||||||
app.mount("/static", StaticFiles(directory=str(static_dir)), name="static")
|
app.mount("/static", StaticFiles(directory=str(static_dir)), name="static")
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/health")
|
||||||
|
async def health() -> JSONResponse:
|
||||||
|
"""Health check endpoint for Kubernetes/Docker.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
JSONResponse: Health status
|
||||||
|
"""
|
||||||
|
return JSONResponse({
|
||||||
|
"status": "ok",
|
||||||
|
"service": "ui",
|
||||||
|
"api_base": API_BASE,
|
||||||
|
"base_path": BASE_PATH
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
@app.get("/", response_class=HTMLResponse)
|
@app.get("/", response_class=HTMLResponse)
|
||||||
async def index(request: Request) -> HTMLResponse:
|
async def index(request: Request) -> HTMLResponse:
|
||||||
"""Redirect to dashboard.
|
"""Redirect to dashboard.
|
||||||
@@ -53,11 +88,11 @@ async def dashboard(request: Request) -> HTMLResponse:
|
|||||||
HTMLResponse: Rendered dashboard template
|
HTMLResponse: Rendered dashboard template
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
# Load layout from API
|
# Load layout from API (use configured API_BASE)
|
||||||
layout_data = fetch_layout()
|
layout_data = fetch_layout(API_BASE)
|
||||||
|
|
||||||
# Fetch devices from API (now includes features)
|
# Fetch devices from API (now includes features)
|
||||||
api_devices = fetch_devices()
|
api_devices = fetch_devices(API_BASE)
|
||||||
|
|
||||||
# Create device lookup by device_id
|
# Create device lookup by device_id
|
||||||
device_map = {d["device_id"]: d for d in api_devices}
|
device_map = {d["device_id"]: d for d in api_devices}
|
||||||
@@ -98,7 +133,8 @@ async def dashboard(request: Request) -> HTMLResponse:
|
|||||||
|
|
||||||
return templates.TemplateResponse("dashboard.html", {
|
return templates.TemplateResponse("dashboard.html", {
|
||||||
"request": request,
|
"request": request,
|
||||||
"rooms": rooms
|
"rooms": rooms,
|
||||||
|
"api_base": API_BASE # Pass API_BASE to template
|
||||||
})
|
})
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -106,7 +142,8 @@ async def dashboard(request: Request) -> HTMLResponse:
|
|||||||
# Fallback to empty dashboard
|
# Fallback to empty dashboard
|
||||||
return templates.TemplateResponse("dashboard.html", {
|
return templates.TemplateResponse("dashboard.html", {
|
||||||
"request": request,
|
"request": request,
|
||||||
"rooms": []
|
"rooms": [],
|
||||||
|
"api_base": API_BASE # Pass API_BASE even on error
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
4
apps/ui/requirements.txt
Normal file
4
apps/ui/requirements.txt
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
fastapi==0.104.1
|
||||||
|
uvicorn[standard]==0.24.0
|
||||||
|
jinja2==3.1.2
|
||||||
|
httpx==0.25.1
|
||||||
@@ -55,14 +55,29 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.room {
|
.room {
|
||||||
|
background: white;
|
||||||
|
border-radius: 20px;
|
||||||
|
padding: 2rem;
|
||||||
margin-bottom: 2rem;
|
margin-bottom: 2rem;
|
||||||
|
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.12);
|
||||||
|
transition: transform 0.2s, box-shadow 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.room:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.15);
|
||||||
}
|
}
|
||||||
|
|
||||||
.room-title {
|
.room-title {
|
||||||
color: white;
|
color: #333;
|
||||||
font-size: 1.5rem;
|
font-size: 1.75rem;
|
||||||
margin-bottom: 1rem;
|
font-weight: 700;
|
||||||
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
|
margin-bottom: 1.5rem;
|
||||||
|
padding-bottom: 1rem;
|
||||||
|
border-bottom: 3px solid #667eea;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.devices {
|
.devices {
|
||||||
@@ -519,7 +534,38 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
const API_BASE = 'http://localhost:8001';
|
// Set room icons based on room name
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
const roomTitles = document.querySelectorAll('.room-title');
|
||||||
|
roomTitles.forEach(title => {
|
||||||
|
const roomName = title.textContent.trim().toLowerCase();
|
||||||
|
let icon = '🏠'; // Default
|
||||||
|
|
||||||
|
if (roomName.includes('wohn') || roomName.includes('living')) icon = '🛋️';
|
||||||
|
else if (roomName.includes('schlaf') || roomName.includes('bed')) icon = '🛏️';
|
||||||
|
else if (roomName.includes('küch') || roomName.includes('kitchen')) icon = '🍳';
|
||||||
|
else if (roomName.includes('bad') || roomName.includes('bath')) icon = '🛁';
|
||||||
|
else if (roomName.includes('büro') || roomName.includes('office')) icon = '💼';
|
||||||
|
else if (roomName.includes('kind') || roomName.includes('child')) icon = '🧸';
|
||||||
|
else if (roomName.includes('garten') || roomName.includes('garden')) icon = '🌿';
|
||||||
|
else if (roomName.includes('garage')) icon = '🚗';
|
||||||
|
else if (roomName.includes('keller') || roomName.includes('basement')) icon = '📦';
|
||||||
|
else if (roomName.includes('dach') || roomName.includes('attic')) icon = '🏚️';
|
||||||
|
|
||||||
|
// Replace the ::before pseudo-element with actual emoji
|
||||||
|
const originalText = title.textContent.trim();
|
||||||
|
title.innerHTML = `${icon} ${originalText}`;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// API_BASE injected from backend (supports Docker/K8s environments)
|
||||||
|
window.API_BASE = '{{ api_base }}';
|
||||||
|
|
||||||
|
// Helper function to construct API URLs
|
||||||
|
function api(url) {
|
||||||
|
return `${window.API_BASE}${url}`;
|
||||||
|
}
|
||||||
|
|
||||||
let eventSource = null;
|
let eventSource = null;
|
||||||
let currentState = {};
|
let currentState = {};
|
||||||
let thermostatTargets = {};
|
let thermostatTargets = {};
|
||||||
@@ -542,7 +588,7 @@
|
|||||||
const newState = currentState[deviceId] === 'on' ? 'off' : 'on';
|
const newState = currentState[deviceId] === 'on' ? 'off' : 'on';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${API_BASE}/devices/${deviceId}/set`, {
|
const response = await fetch(api(`/devices/${deviceId}/set`), {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json'
|
'Content-Type': 'application/json'
|
||||||
@@ -579,7 +625,7 @@
|
|||||||
// Set brightness
|
// Set brightness
|
||||||
async function setBrightness(deviceId, brightness) {
|
async function setBrightness(deviceId, brightness) {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${API_BASE}/devices/${deviceId}/set`, {
|
const response = await fetch(api(`/devices/${deviceId}/set`), {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json'
|
'Content-Type': 'application/json'
|
||||||
@@ -612,7 +658,7 @@
|
|||||||
const newTarget = Math.max(5.0, Math.min(30.0, currentTarget + delta));
|
const newTarget = Math.max(5.0, Math.min(30.0, currentTarget + delta));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${API_BASE}/devices/${deviceId}/set`, {
|
const response = await fetch(api(`/devices/${deviceId}/set`), {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json'
|
'Content-Type': 'application/json'
|
||||||
@@ -627,7 +673,6 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
thermostatTargets[deviceId] = newTarget;
|
|
||||||
console.log(`Sent target ${newTarget} to ${deviceId}`);
|
console.log(`Sent target ${newTarget} to ${deviceId}`);
|
||||||
addEvent({
|
addEvent({
|
||||||
action: 'target_adjusted',
|
action: 'target_adjusted',
|
||||||
@@ -645,7 +690,7 @@
|
|||||||
const currentTarget = thermostatTargets[deviceId] || 21.0;
|
const currentTarget = thermostatTargets[deviceId] || 21.0;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${API_BASE}/devices/${deviceId}/set`, {
|
const response = await fetch(api(`/devices/${deviceId}/set`), {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json'
|
'Content-Type': 'application/json'
|
||||||
@@ -776,76 +821,175 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Connect to SSE
|
// Connect to SSE
|
||||||
|
let reconnectAttempts = 0;
|
||||||
|
const maxReconnectDelay = 30000; // Max 30 seconds
|
||||||
|
|
||||||
function connectSSE() {
|
function connectSSE() {
|
||||||
eventSource = new EventSource(`${API_BASE}/realtime`);
|
// Close existing connection if any
|
||||||
|
if (eventSource) {
|
||||||
eventSource.onopen = () => {
|
try {
|
||||||
console.log('SSE connected');
|
eventSource.close();
|
||||||
document.getElementById('connection-status').textContent = 'Verbunden';
|
} catch (e) {
|
||||||
document.getElementById('connection-status').className = 'status connected';
|
console.error('Error closing EventSource:', e);
|
||||||
};
|
|
||||||
|
|
||||||
eventSource.addEventListener('message', (e) => {
|
|
||||||
const data = JSON.parse(e.data);
|
|
||||||
console.log('SSE message:', data);
|
|
||||||
|
|
||||||
addEvent(data);
|
|
||||||
|
|
||||||
// Update device state
|
|
||||||
if (data.type === 'state' && data.device_id && data.payload) {
|
|
||||||
const card = document.querySelector(`[data-device-id="${data.device_id}"]`);
|
|
||||||
|
|
||||||
// Check if it's a light
|
|
||||||
if (data.payload.power !== undefined) {
|
|
||||||
updateDeviceUI(
|
|
||||||
data.device_id,
|
|
||||||
data.payload.power,
|
|
||||||
data.payload.brightness
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if it's a thermostat
|
|
||||||
if (data.payload.mode !== undefined || data.payload.target !== undefined || data.payload.current !== undefined) {
|
|
||||||
updateThermostatUI(
|
|
||||||
data.device_id,
|
|
||||||
data.payload.current,
|
|
||||||
data.payload.target,
|
|
||||||
data.payload.mode
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
eventSource = null;
|
||||||
|
}
|
||||||
|
|
||||||
eventSource.addEventListener('ping', (e) => {
|
console.log(`Connecting to SSE... (attempt ${reconnectAttempts + 1})`);
|
||||||
console.log('Heartbeat received');
|
|
||||||
});
|
|
||||||
|
|
||||||
eventSource.onerror = (error) => {
|
try {
|
||||||
console.error('SSE error:', error);
|
eventSource = new EventSource(api('/realtime'));
|
||||||
|
|
||||||
|
eventSource.onopen = () => {
|
||||||
|
console.log('SSE connected successfully');
|
||||||
|
reconnectAttempts = 0; // Reset counter on successful connection
|
||||||
|
document.getElementById('connection-status').textContent = 'Verbunden';
|
||||||
|
document.getElementById('connection-status').className = 'status connected';
|
||||||
|
};
|
||||||
|
|
||||||
|
eventSource.addEventListener('message', (e) => {
|
||||||
|
const data = JSON.parse(e.data);
|
||||||
|
console.log('SSE message:', data);
|
||||||
|
|
||||||
|
addEvent(data);
|
||||||
|
|
||||||
|
// Update device state
|
||||||
|
if (data.type === 'state' && data.device_id && data.payload) {
|
||||||
|
const card = document.querySelector(`[data-device-id="${data.device_id}"]`);
|
||||||
|
if (!card) {
|
||||||
|
console.warn(`No card found for device ${data.device_id}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if it's a light
|
||||||
|
if (data.payload.power !== undefined) {
|
||||||
|
currentState[data.device_id] = data.payload.power;
|
||||||
|
updateDeviceUI(
|
||||||
|
data.device_id,
|
||||||
|
data.payload.power,
|
||||||
|
data.payload.brightness
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if it's a thermostat
|
||||||
|
if (data.payload.mode !== undefined || data.payload.target !== undefined || data.payload.current !== undefined) {
|
||||||
|
if (data.payload.mode !== undefined) {
|
||||||
|
thermostatModes[data.device_id] = data.payload.mode;
|
||||||
|
}
|
||||||
|
if (data.payload.target !== undefined) {
|
||||||
|
thermostatTargets[data.device_id] = data.payload.target;
|
||||||
|
}
|
||||||
|
updateThermostatUI(
|
||||||
|
data.device_id,
|
||||||
|
data.payload.current,
|
||||||
|
data.payload.target,
|
||||||
|
data.payload.mode
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
eventSource.addEventListener('ping', (e) => {
|
||||||
|
console.log('Heartbeat received');
|
||||||
|
});
|
||||||
|
|
||||||
|
eventSource.onerror = (error) => {
|
||||||
|
console.error('SSE error:', error, 'readyState:', eventSource?.readyState);
|
||||||
|
document.getElementById('connection-status').textContent = 'Getrennt';
|
||||||
|
document.getElementById('connection-status').className = 'status disconnected';
|
||||||
|
|
||||||
|
if (eventSource) {
|
||||||
|
try {
|
||||||
|
eventSource.close();
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error closing EventSource on error:', e);
|
||||||
|
}
|
||||||
|
eventSource = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exponential backoff with max delay
|
||||||
|
reconnectAttempts++;
|
||||||
|
const delay = Math.min(
|
||||||
|
1000 * Math.pow(2, reconnectAttempts - 1),
|
||||||
|
maxReconnectDelay
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(`Reconnecting in ${delay}ms... (attempt ${reconnectAttempts})`);
|
||||||
|
setTimeout(connectSSE, delay);
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to create EventSource:', error);
|
||||||
document.getElementById('connection-status').textContent = 'Getrennt';
|
document.getElementById('connection-status').textContent = 'Getrennt';
|
||||||
document.getElementById('connection-status').className = 'status disconnected';
|
document.getElementById('connection-status').className = 'status disconnected';
|
||||||
eventSource.close();
|
|
||||||
|
|
||||||
// Reconnect after 5 seconds
|
reconnectAttempts++;
|
||||||
setTimeout(connectSSE, 5000);
|
const delay = Math.min(
|
||||||
};
|
1000 * Math.pow(2, reconnectAttempts - 1),
|
||||||
|
maxReconnectDelay
|
||||||
|
);
|
||||||
|
|
||||||
|
setTimeout(connectSSE, delay);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Safari/iOS specific: Reconnect when page becomes visible
|
||||||
|
document.addEventListener('visibilitychange', () => {
|
||||||
|
if (document.visibilityState === 'visible') {
|
||||||
|
console.log('Page visible, checking SSE connection...');
|
||||||
|
// Only reconnect if connection is actually dead (CLOSED = 2)
|
||||||
|
if (!eventSource || eventSource.readyState === EventSource.CLOSED) {
|
||||||
|
console.log('SSE connection dead, forcing reconnect...');
|
||||||
|
reconnectAttempts = 0; // Reset for immediate reconnect
|
||||||
|
connectSSE();
|
||||||
|
} else {
|
||||||
|
console.log('SSE connection OK, readyState:', eventSource.readyState);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Safari/iOS specific: Reconnect on page focus
|
||||||
|
window.addEventListener('focus', () => {
|
||||||
|
console.log('Window focused, checking SSE connection...');
|
||||||
|
// Only reconnect if connection is actually dead (CLOSED = 2)
|
||||||
|
if (!eventSource || eventSource.readyState === EventSource.CLOSED) {
|
||||||
|
console.log('SSE connection dead, forcing reconnect...');
|
||||||
|
reconnectAttempts = 0; // Reset for immediate reconnect
|
||||||
|
connectSSE();
|
||||||
|
} else {
|
||||||
|
console.log('SSE connection OK, readyState:', eventSource.readyState);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Initialize
|
// Initialize
|
||||||
connectSSE();
|
connectSSE();
|
||||||
|
|
||||||
// Optional: Load initial state from API
|
// Load initial device states
|
||||||
async function loadDevices() {
|
async function loadDevices() {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${API_BASE}/devices`);
|
const response = await fetch(api('/devices'));
|
||||||
const devices = await response.json();
|
const devices = await response.json();
|
||||||
console.log('Loaded devices:', devices);
|
console.log('Loaded initial device states:', devices);
|
||||||
|
|
||||||
|
// Update UI with initial states
|
||||||
|
devices.forEach(device => {
|
||||||
|
if (device.type === 'light' && device.state) {
|
||||||
|
currentState[device.id] = device.state.power;
|
||||||
|
updateDeviceUI(device.id, device.state.power, device.state.brightness);
|
||||||
|
} else if (device.type === 'thermostat' && device.state) {
|
||||||
|
if (device.state.mode) thermostatModes[device.id] = device.state.mode;
|
||||||
|
if (device.state.target) thermostatTargets[device.id] = device.state.target;
|
||||||
|
updateThermostatUI(device.id, device.state.current, device.state.target, device.state.mode);
|
||||||
|
}
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load devices:', error);
|
console.error('Failed to load initial device states:', error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
loadDevices();
|
// Load initial states before connecting SSE
|
||||||
|
loadDevices().then(() => {
|
||||||
|
console.log('Initial states loaded, now connecting SSE...');
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
Reference in New Issue
Block a user