485 lines
16 KiB
HTML
485 lines
16 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="de">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>Raum - Home Automation</title>
|
|
<style>
|
|
* {
|
|
margin: 0;
|
|
padding: 0;
|
|
box-sizing: border-box;
|
|
}
|
|
|
|
body {
|
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
|
min-height: 100vh;
|
|
padding: 20px;
|
|
}
|
|
|
|
.container {
|
|
max-width: 1200px;
|
|
margin: 0 auto;
|
|
}
|
|
|
|
.header {
|
|
background: rgba(255, 255, 255, 0.95);
|
|
border-radius: 12px;
|
|
padding: 20px;
|
|
margin-bottom: 20px;
|
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
|
}
|
|
|
|
.back-button {
|
|
background: none;
|
|
border: none;
|
|
color: #667eea;
|
|
font-size: 16px;
|
|
cursor: pointer;
|
|
padding: 8px 0;
|
|
margin-bottom: 12px;
|
|
display: inline-block;
|
|
text-decoration: none;
|
|
}
|
|
|
|
.back-button:hover {
|
|
text-decoration: underline;
|
|
}
|
|
|
|
h1 {
|
|
color: #333;
|
|
font-size: 28px;
|
|
margin-bottom: 8px;
|
|
}
|
|
|
|
.room-info {
|
|
color: #666;
|
|
font-size: 14px;
|
|
}
|
|
|
|
.devices-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(2, 1fr);
|
|
gap: 16px;
|
|
}
|
|
|
|
@media (min-width: 600px) {
|
|
.devices-grid {
|
|
grid-template-columns: repeat(3, 1fr);
|
|
}
|
|
}
|
|
|
|
@media (min-width: 900px) {
|
|
.devices-grid {
|
|
grid-template-columns: repeat(4, 1fr);
|
|
}
|
|
}
|
|
|
|
.device-card {
|
|
background: rgba(255, 255, 255, 0.95);
|
|
border-radius: 12px;
|
|
padding: 16px;
|
|
cursor: pointer;
|
|
transition: all 0.2s ease;
|
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
|
min-height: 120px;
|
|
display: flex;
|
|
flex-direction: column;
|
|
text-decoration: none;
|
|
color: inherit;
|
|
}
|
|
|
|
.device-card:hover {
|
|
transform: translateY(-4px);
|
|
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.2);
|
|
}
|
|
|
|
.device-card:active {
|
|
transform: translateY(-2px);
|
|
}
|
|
|
|
.device-header {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 12px;
|
|
margin-bottom: 12px;
|
|
}
|
|
|
|
.device-icon {
|
|
font-size: 24px;
|
|
}
|
|
|
|
.device-title {
|
|
font-size: 16px;
|
|
font-weight: 600;
|
|
color: #333;
|
|
flex: 1;
|
|
line-height: 1.3;
|
|
}
|
|
|
|
.device-state {
|
|
margin-top: auto;
|
|
}
|
|
|
|
.state-primary {
|
|
font-size: 20px;
|
|
font-weight: 600;
|
|
color: #667eea;
|
|
margin-bottom: 4px;
|
|
}
|
|
|
|
.state-secondary {
|
|
font-size: 14px;
|
|
color: #666;
|
|
}
|
|
|
|
.state-badge {
|
|
display: inline-block;
|
|
padding: 4px 12px;
|
|
border-radius: 12px;
|
|
font-size: 13px;
|
|
font-weight: 500;
|
|
}
|
|
|
|
.state-badge.on {
|
|
background: #34c759;
|
|
color: white;
|
|
}
|
|
|
|
.state-badge.off {
|
|
background: #e0e0e0;
|
|
color: #666;
|
|
}
|
|
|
|
.state-badge.open {
|
|
background: #ff9500;
|
|
color: white;
|
|
}
|
|
|
|
.state-badge.closed {
|
|
background: #34c759;
|
|
color: white;
|
|
}
|
|
|
|
.loading {
|
|
text-align: center;
|
|
color: white;
|
|
font-size: 18px;
|
|
padding: 40px;
|
|
}
|
|
|
|
.error {
|
|
background: rgba(255, 59, 48, 0.9);
|
|
color: white;
|
|
padding: 16px;
|
|
border-radius: 8px;
|
|
text-align: center;
|
|
margin-bottom: 20px;
|
|
}
|
|
|
|
.no-devices {
|
|
background: rgba(255, 255, 255, 0.95);
|
|
border-radius: 12px;
|
|
padding: 40px;
|
|
text-align: center;
|
|
color: #666;
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="container">
|
|
<div class="header">
|
|
<a href="/rooms" class="back-button">← Zurück zu Räumen</a>
|
|
<h1 id="room-name">Raum wird geladen...</h1>
|
|
<div class="room-info" id="room-info"></div>
|
|
</div>
|
|
|
|
<div id="error-container"></div>
|
|
<div id="loading" class="loading">Lade Geräte...</div>
|
|
<div id="devices-grid" class="devices-grid" style="display: none;"></div>
|
|
<div id="no-devices" class="no-devices" style="display: none;">
|
|
Keine Geräte in diesem Raum gefunden.
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
// API configuration from backend
|
|
window.API_BASE = '{{ api_base }}';
|
|
|
|
// Helper function to construct API URLs
|
|
function api(url) {
|
|
return `${window.API_BASE}${url}`;
|
|
}
|
|
|
|
// Get room name from URL
|
|
const pathParts = window.location.pathname.split('/');
|
|
const roomName = decodeURIComponent(pathParts[pathParts.length - 1]);
|
|
|
|
// Device type to icon mapping
|
|
const deviceIcons = {
|
|
'light': '💡',
|
|
'thermostat': '🌡️',
|
|
'contact': '🚪',
|
|
'temp_humidity_sensor': '🌡️',
|
|
'relay': '🔌',
|
|
'outlet': '🔌',
|
|
'cover': '🪟'
|
|
};
|
|
|
|
// Device states
|
|
const deviceStates = {};
|
|
|
|
// SSE connection
|
|
let eventSource = null;
|
|
|
|
async function loadRoom() {
|
|
const loading = document.getElementById('loading');
|
|
const grid = document.getElementById('devices-grid');
|
|
const noDevices = document.getElementById('no-devices');
|
|
const errorContainer = document.getElementById('error-container');
|
|
const roomNameEl = document.getElementById('room-name');
|
|
const roomInfoEl = document.getElementById('room-info');
|
|
|
|
try {
|
|
// Load layout
|
|
const layoutResponse = await fetch(api('/layout'));
|
|
if (!layoutResponse.ok) {
|
|
throw new Error(`Layout API error: ${layoutResponse.status}`);
|
|
}
|
|
const layoutData = await layoutResponse.json();
|
|
|
|
// Find the room
|
|
const room = layoutData.rooms.find(r => r.name === roomName);
|
|
if (!room) {
|
|
throw new Error(`Raum "${roomName}" nicht gefunden`);
|
|
}
|
|
|
|
// Update header
|
|
roomNameEl.textContent = room.name;
|
|
roomInfoEl.textContent = `${room.devices.length} Gerät${room.devices.length !== 1 ? 'e' : ''}`;
|
|
|
|
// Load devices
|
|
const devicesResponse = await fetch(api('/devices'));
|
|
if (!devicesResponse.ok) {
|
|
throw new Error(`Devices API error: ${devicesResponse.status}`);
|
|
}
|
|
const devicesData = await devicesResponse.json();
|
|
|
|
// Create device lookup
|
|
const deviceMap = {};
|
|
devicesData.forEach(device => {
|
|
deviceMap[device.device_id] = device;
|
|
});
|
|
|
|
// Filter devices for this room
|
|
const roomDevices = room.devices
|
|
.map(deviceId => deviceMap[deviceId])
|
|
.filter(device => device != null);
|
|
|
|
loading.style.display = 'none';
|
|
|
|
if (roomDevices.length === 0) {
|
|
noDevices.style.display = 'block';
|
|
return;
|
|
}
|
|
|
|
// Load initial states
|
|
await loadDeviceStates(roomDevices.map(d => d.device_id));
|
|
|
|
// Render devices
|
|
grid.style.display = 'grid';
|
|
roomDevices.forEach(device => {
|
|
const card = createDeviceCard(device);
|
|
grid.appendChild(card);
|
|
});
|
|
|
|
// Start SSE for live updates
|
|
connectRealtime();
|
|
|
|
} catch (error) {
|
|
console.error('Error loading room:', error);
|
|
loading.style.display = 'none';
|
|
errorContainer.innerHTML = `
|
|
<div class="error">
|
|
⚠️ Fehler beim Laden: ${error.message}
|
|
</div>
|
|
`;
|
|
}
|
|
}
|
|
|
|
async function loadDeviceStates(deviceIds) {
|
|
try {
|
|
// Load states for all devices
|
|
const statePromises = deviceIds.map(async deviceId => {
|
|
try {
|
|
const response = await fetch(api(`/devices/${deviceId}/state`));
|
|
if (response.ok) {
|
|
const state = await response.json();
|
|
deviceStates[deviceId] = state;
|
|
}
|
|
} catch (e) {
|
|
console.warn(`Failed to load state for ${deviceId}:`, e);
|
|
}
|
|
});
|
|
await Promise.all(statePromises);
|
|
} catch (error) {
|
|
console.error('Error loading device states:', error);
|
|
}
|
|
}
|
|
|
|
function createDeviceCard(device) {
|
|
const card = document.createElement('a');
|
|
card.className = 'device-card';
|
|
card.href = `/device/${device.device_id}`;
|
|
card.dataset.deviceId = device.device_id;
|
|
|
|
const header = document.createElement('div');
|
|
header.className = 'device-header';
|
|
|
|
const icon = document.createElement('div');
|
|
icon.className = 'device-icon';
|
|
icon.textContent = deviceIcons[device.type] || '📱';
|
|
|
|
const title = document.createElement('div');
|
|
title.className = 'device-title';
|
|
title.textContent = device.name;
|
|
|
|
header.appendChild(icon);
|
|
header.appendChild(title);
|
|
|
|
const stateDiv = document.createElement('div');
|
|
stateDiv.className = 'device-state';
|
|
updateDeviceCardState(stateDiv, device);
|
|
|
|
card.appendChild(header);
|
|
card.appendChild(stateDiv);
|
|
|
|
return card;
|
|
}
|
|
|
|
function updateDeviceCardState(stateDiv, device) {
|
|
const state = deviceStates[device.device_id];
|
|
if (!state) {
|
|
stateDiv.innerHTML = '<div class="state-secondary">Status unbekannt</div>';
|
|
return;
|
|
}
|
|
|
|
let html = '';
|
|
|
|
switch (device.type) {
|
|
case 'light':
|
|
if (state.power) {
|
|
const powerState = state.power === 'on';
|
|
html = `<span class="state-badge ${powerState ? 'on' : 'off'}">${powerState ? 'An' : 'Aus'}</span>`;
|
|
if (powerState && state.brightness != null) {
|
|
html += `<div class="state-secondary">${state.brightness}% Helligkeit</div>`;
|
|
}
|
|
}
|
|
break;
|
|
|
|
case 'thermostat':
|
|
if (state.current_temp != null) {
|
|
html = `<div class="state-primary">${state.current_temp.toFixed(1)}°C</div>`;
|
|
if (state.target_temp != null) {
|
|
html += `<div class="state-secondary">Ziel: ${state.target_temp}°C</div>`;
|
|
}
|
|
}
|
|
break;
|
|
|
|
case 'contact':
|
|
if (state.contact != null) {
|
|
const isOpen = state.contact === 'open';
|
|
html = `<span class="state-badge ${isOpen ? 'open' : 'closed'}">${isOpen ? 'Offen' : 'Geschlossen'}</span>`;
|
|
}
|
|
break;
|
|
|
|
case 'temp_humidity_sensor':
|
|
if (state.temperature != null) {
|
|
html = `<div class="state-primary">${state.temperature.toFixed(1)}°C</div>`;
|
|
if (state.humidity != null) {
|
|
html += `<div class="state-secondary">${state.humidity}% Luftfeuchte</div>`;
|
|
}
|
|
}
|
|
break;
|
|
|
|
case 'relay':
|
|
case 'outlet':
|
|
if (state.power) {
|
|
const powerState = state.power === 'on';
|
|
html = `<span class="state-badge ${powerState ? 'on' : 'off'}">${powerState ? 'An' : 'Aus'}</span>`;
|
|
}
|
|
break;
|
|
|
|
case 'cover':
|
|
if (state.position != null) {
|
|
html = `<div class="state-primary">${state.position}%</div>`;
|
|
html += `<div class="state-secondary">Position</div>`;
|
|
}
|
|
break;
|
|
|
|
default:
|
|
html = '<div class="state-secondary">Kein Status</div>';
|
|
}
|
|
|
|
stateDiv.innerHTML = html;
|
|
}
|
|
|
|
function connectRealtime() {
|
|
try {
|
|
eventSource = new EventSource(api('/realtime'));
|
|
|
|
eventSource.onmessage = (event) => {
|
|
try {
|
|
const data = JSON.parse(event.data);
|
|
|
|
// Update device state
|
|
if (data.device_id && data.payload) {
|
|
deviceStates[data.device_id] = data.payload;
|
|
|
|
// Update card if visible
|
|
const card = document.querySelector(`[data-device-id="${data.device_id}"]`);
|
|
if (card) {
|
|
const stateDiv = card.querySelector('.device-state');
|
|
const devicesResponse = fetch(api('/devices')).then(r => r.json()).then(devices => {
|
|
const device = devices.find(d => d.device_id === data.device_id);
|
|
if (device) {
|
|
updateDeviceCardState(stateDiv, device);
|
|
}
|
|
});
|
|
}
|
|
}
|
|
} catch (e) {
|
|
console.warn('Failed to parse SSE event:', e);
|
|
}
|
|
};
|
|
|
|
eventSource.onerror = (error) => {
|
|
console.error('SSE connection error:', error);
|
|
// Auto-reconnect after 5 seconds
|
|
setTimeout(() => {
|
|
if (eventSource) {
|
|
eventSource.close();
|
|
connectRealtime();
|
|
}
|
|
}, 5000);
|
|
};
|
|
|
|
} catch (error) {
|
|
console.error('Failed to connect to realtime events:', error);
|
|
}
|
|
}
|
|
|
|
// Cleanup on page unload
|
|
window.addEventListener('beforeunload', () => {
|
|
if (eventSource) {
|
|
eventSource.close();
|
|
}
|
|
});
|
|
|
|
// Load room on page load
|
|
document.addEventListener('DOMContentLoaded', loadRoom);
|
|
</script>
|
|
</body>
|
|
</html>
|