Compare commits

...

8 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
8 changed files with 494 additions and 82 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.

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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