Compare commits

..

10 Commits

Author SHA1 Message Date
eb822c0318 fixes 2 2025-11-08 17:48:38 +01:00
acb5e0a209 fixes 2025-11-08 17:36:52 +01:00
4b196c1278 iphone fix 2025-11-08 16:23:11 +01:00
7e04991d64 room cards 2 2025-11-08 16:04:46 +01:00
cc3364068a room cards 2025-11-08 16:03:58 +01:00
c1cbca39bf cors 2025-11-08 15:59:18 +01:00
6271f46019 use correct broker setting 2025-11-08 15:56:03 +01:00
6bf8ac3f99 docs 2025-11-06 16:50:23 +01:00
b7efae61c4 docs 2025-11-06 13:46:19 +01:00
e76cb3dc21 dockerfiles added 2025-11-06 13:39:42 +01:00
20 changed files with 1270 additions and 103 deletions

230
DOCKER_GUIDE.md Normal file
View 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
View 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

View 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"]

View File

@@ -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)

View File

@@ -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]

View 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
View 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"]

View File

@@ -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`

View File

@@ -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")

View 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
View 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"]

View File

@@ -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

View 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
View 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"]

View File

@@ -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
View 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)
```

View File

@@ -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:

View File

@@ -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
View File

@@ -0,0 +1,4 @@
fastapi==0.104.1
uvicorn[standard]==0.24.0
jinja2==3.1.2
httpx==0.25.1

View File

@@ -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>