Compare commits
8 Commits
b7efae61c4
...
works_on_c
| Author | SHA1 | Date | |
|---|---|---|---|
|
eb822c0318
|
|||
|
acb5e0a209
|
|||
|
4b196c1278
|
|||
|
7e04991d64
|
|||
|
cc3364068a
|
|||
|
c1cbca39bf
|
|||
|
6271f46019
|
|||
|
6bf8ac3f99
|
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.
|
||||
@@ -32,7 +32,7 @@ docker build -t abstraction:dev -f apps/abstraction/Dockerfile .
|
||||
```bash
|
||||
docker run --rm \
|
||||
-v $(pwd)/config:/app/config:ro \
|
||||
-e MQTT_BROKER=172.16.2.16 \
|
||||
-e MQTT_BROKER=172.23.1.102 \
|
||||
-e MQTT_PORT=1883 \
|
||||
-e REDIS_HOST=172.23.1.116 \
|
||||
-e REDIS_PORT=6379 \
|
||||
|
||||
@@ -38,8 +38,8 @@ def load_config(config_path: Path) -> dict[str, Any]:
|
||||
logger.warning(f"Config file not found: {config_path}, using defaults")
|
||||
return {
|
||||
"mqtt": {
|
||||
"broker": "172.16.2.16",
|
||||
"port": 1883,
|
||||
"broker": os.getenv("MQTT_BROKER", "localhost"),
|
||||
"port": int(os.getenv("MQTT_PORT", "1883")),
|
||||
"client_id": "home-automation-abstraction",
|
||||
"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
|
||||
"""
|
||||
mqtt_config = config.get("mqtt", {})
|
||||
broker = mqtt_config.get("broker", "172.16.2.16")
|
||||
port = mqtt_config.get("port", 1883)
|
||||
broker = os.getenv("MQTT_BROKER") or mqtt_config.get("broker", "localhost")
|
||||
port = int(os.getenv("MQTT_PORT", mqtt_config.get("port", 1883)))
|
||||
client_id = mqtt_config.get("client_id", "home-automation-abstraction")
|
||||
# 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]
|
||||
|
||||
@@ -42,7 +42,7 @@ docker build -t api:dev -f apps/api/Dockerfile .
|
||||
```bash
|
||||
docker run --rm -p 8001:8001 \
|
||||
-v $(pwd)/config:/app/config:ro \
|
||||
-e MQTT_BROKER=172.16.2.16 \
|
||||
-e MQTT_BROKER=172.23.1.102 \
|
||||
-e MQTT_PORT=1883 \
|
||||
-e REDIS_HOST=172.23.1.116 \
|
||||
-e REDIS_PORT=6379 \
|
||||
@@ -51,6 +51,23 @@ docker run --rm -p 8001:8001 \
|
||||
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 |
|
||||
|
||||
@@ -30,6 +30,7 @@ app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=[
|
||||
"http://localhost:8002",
|
||||
"http://172.19.1.11:8002",
|
||||
"http://127.0.0.1:8002",
|
||||
],
|
||||
allow_credentials=True,
|
||||
@@ -300,41 +301,42 @@ async def event_generator(request: Request) -> AsyncGenerator[str, None]:
|
||||
|
||||
try:
|
||||
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()
|
||||
heartbeat_interval = 25
|
||||
|
||||
while True:
|
||||
# Check if client disconnected
|
||||
if await request.is_disconnected():
|
||||
logger.info("SSE client disconnected")
|
||||
break
|
||||
|
||||
# Get message with timeout for heartbeat
|
||||
try:
|
||||
message = await asyncio.wait_for(
|
||||
pubsub.get_message(ignore_subscribe_messages=True),
|
||||
timeout=1.0
|
||||
)
|
||||
|
||||
if message and message["type"] == "message":
|
||||
# Send data event
|
||||
data = message["data"]
|
||||
yield f"event: message\ndata: {data}\n\n"
|
||||
last_heartbeat = asyncio.get_event_loop().time()
|
||||
|
||||
except asyncio.TimeoutError:
|
||||
pass
|
||||
# Try to get message (non-blocking)
|
||||
message = await pubsub.get_message(ignore_subscribe_messages=True, timeout=0.1)
|
||||
|
||||
# Handle actual data messages
|
||||
if message and message["type"] == "message":
|
||||
data = message["data"]
|
||||
logger.debug(f"Sending SSE message: {data[:100]}...")
|
||||
yield f"event: message\ndata: {data}\n\n"
|
||||
last_heartbeat = asyncio.get_event_loop().time()
|
||||
else:
|
||||
# No message, sleep a bit to avoid busy loop
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
# Send heartbeat every 25 seconds
|
||||
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"
|
||||
last_heartbeat = current_time
|
||||
|
||||
finally:
|
||||
await pubsub.unsubscribe(redis_channel)
|
||||
await pubsub.close()
|
||||
await redis_client.close()
|
||||
await pubsub.aclose()
|
||||
await redis_client.aclose()
|
||||
logger.info("SSE connection closed")
|
||||
|
||||
|
||||
@app.get("/realtime")
|
||||
|
||||
@@ -53,12 +53,20 @@ docker build -t simulator:dev -f apps/simulator/Dockerfile .
|
||||
|
||||
```bash
|
||||
docker run --rm -p 8010:8010 \
|
||||
-e MQTT_BROKER=172.16.2.16 \
|
||||
-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 |
|
||||
|
||||
@@ -37,14 +37,32 @@ 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://localhost:8001 \
|
||||
-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 |
|
||||
|
||||
@@ -55,14 +55,29 @@
|
||||
}
|
||||
|
||||
.room {
|
||||
background: white;
|
||||
border-radius: 20px;
|
||||
padding: 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 {
|
||||
color: white;
|
||||
font-size: 1.5rem;
|
||||
margin-bottom: 1rem;
|
||||
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
|
||||
color: #333;
|
||||
font-size: 1.75rem;
|
||||
font-weight: 700;
|
||||
margin-bottom: 1.5rem;
|
||||
padding-bottom: 1rem;
|
||||
border-bottom: 3px solid #667eea;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.devices {
|
||||
@@ -519,6 +534,30 @@
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// 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 }}';
|
||||
|
||||
@@ -634,7 +673,6 @@
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
thermostatTargets[deviceId] = newTarget;
|
||||
console.log(`Sent target ${newTarget} to ${deviceId}`);
|
||||
addEvent({
|
||||
action: 'target_adjusted',
|
||||
@@ -783,76 +821,175 @@
|
||||
}
|
||||
|
||||
// Connect to SSE
|
||||
let reconnectAttempts = 0;
|
||||
const maxReconnectDelay = 30000; // Max 30 seconds
|
||||
|
||||
function connectSSE() {
|
||||
eventSource = new EventSource(api('/realtime'));
|
||||
|
||||
eventSource.onopen = () => {
|
||||
console.log('SSE connected');
|
||||
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}"]`);
|
||||
|
||||
// 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
|
||||
);
|
||||
}
|
||||
// Close existing connection if any
|
||||
if (eventSource) {
|
||||
try {
|
||||
eventSource.close();
|
||||
} catch (e) {
|
||||
console.error('Error closing EventSource:', e);
|
||||
}
|
||||
});
|
||||
eventSource = null;
|
||||
}
|
||||
|
||||
eventSource.addEventListener('ping', (e) => {
|
||||
console.log('Heartbeat received');
|
||||
});
|
||||
console.log(`Connecting to SSE... (attempt ${reconnectAttempts + 1})`);
|
||||
|
||||
eventSource.onerror = (error) => {
|
||||
console.error('SSE error:', error);
|
||||
try {
|
||||
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').className = 'status disconnected';
|
||||
eventSource.close();
|
||||
|
||||
// Reconnect after 5 seconds
|
||||
setTimeout(connectSSE, 5000);
|
||||
};
|
||||
reconnectAttempts++;
|
||||
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
|
||||
connectSSE();
|
||||
|
||||
// Optional: Load initial state from API
|
||||
// Load initial device states
|
||||
async function loadDevices() {
|
||||
try {
|
||||
const response = await fetch(api('/devices'));
|
||||
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) {
|
||||
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>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user