Files
home-automation/apps/ui/templates/dashboard.html
2025-11-17 08:42:26 +01:00

1964 lines
70 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Home Automation</title>
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg">
<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: 2rem;
}
.container {
max-width: 1200px;
margin: 0 auto;
}
header {
background: white;
border-radius: 16px;
padding: 2rem;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);
margin-bottom: 2rem;
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 1rem;
flex-wrap: wrap;
}
.header-content {
flex: 1;
min-width: 200px;
}
h1 {
color: #333;
margin-bottom: 0.5rem;
}
.header-buttons {
display: flex;
gap: 0.5rem;
align-items: center;
}
.refresh-btn,
.collapse-all-btn {
padding: 0.75rem;
background: #667eea;
color: white;
border: none;
border-radius: 8px;
cursor: pointer;
transition: all 0.2s;
min-height: 44px;
min-width: 44px;
display: flex;
align-items: center;
justify-content: center;
}
.refresh-btn:hover,
.collapse-all-btn:hover {
background: #5568d3;
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.3);
}
.refresh-btn:active,
.collapse-all-btn:active {
transform: translateY(0);
}
.refresh-icon {
font-size: 1.5rem;
line-height: 1;
transition: transform 0.3s;
}
.refresh-icon.spinning {
animation: spin 0.6s linear;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.collapse-all-icon {
font-size: 1.25rem;
transition: transform 0.3s;
line-height: 1;
}
.collapse-all-icon.collapsed {
transform: rotate(-90deg);
}
.status {
display: inline-block;
padding: 0.25rem 0.75rem;
border-radius: 12px;
font-size: 0.875rem;
font-weight: 500;
}
.status.connected {
background: #d4edda;
color: #155724;
}
.status.disconnected {
background: #f8d7da;
color: #721c24;
}
.room {
background: white;
border-radius: 20px;
margin-bottom: 1rem;
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.12);
overflow: hidden;
transition: box-shadow 0.2s;
}
.room:hover {
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.15);
}
.room-header {
padding: 1.5rem 2rem;
cursor: pointer;
display: flex;
align-items: center;
justify-content: space-between;
background: white;
transition: background-color 0.2s;
user-select: none;
}
.room-header:hover {
background: #f8f9fa;
}
.room-header:active {
background: #e9ecef;
}
.room-title {
color: #333;
font-size: 1.5rem;
font-weight: 700;
display: flex;
align-items: center;
gap: 0.75rem;
margin: 0;
}
.room-title h2 {
font-size: 1.5rem;
font-weight: 700;
margin: 0;
}
.room-toggle {
font-size: 1.5rem;
color: #667eea;
transition: transform 0.3s;
line-height: 1;
}
.room-toggle.collapsed {
transform: rotate(-90deg);
}
.room-content {
padding: 0 2rem 2rem 2rem;
max-height: 5000px;
overflow: hidden;
transition: max-height 0.3s ease-out, padding 0.3s ease-out;
}
.room-content.collapsed {
max-height: 0;
padding-top: 0;
padding-bottom: 0;
}
.devices {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 1.5rem;
}
.device-card {
background: white;
border-radius: 16px;
padding: 1.5rem;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);
}
.device-header {
margin-bottom: 1.5rem;
}
.device-name {
font-size: 1.5rem;
font-weight: 600;
color: #333;
margin-bottom: 0.25rem;
}
.device-type {
font-size: 0.875rem;
color: #666;
text-transform: uppercase;
}
.device-id {
font-size: 0.75rem;
color: #999;
margin-top: 0.25rem;
font-family: 'Courier New', monospace;
}
.device-state {
padding: 0.5rem 1rem;
background: #f8f9fa;
border-radius: 8px;
margin: 1rem 0;
font-family: 'Courier New', monospace;
font-size: 0.875rem;
}
.state-label {
color: #666;
font-weight: 500;
}
.state-value {
color: #333;
font-weight: 600;
}
.state-value.on {
color: #28a745;
}
.state-value.off {
color: #dc3545;
}
.controls {
display: flex;
flex-direction: column;
gap: 1rem;
}
.toggle-button {
padding: 0.75rem 1.5rem;
border: none;
border-radius: 8px;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
color: white;
}
.toggle-button.on {
background: #28a745;
}
.toggle-button.on:hover {
background: #218838;
}
.toggle-button.off {
background: #6c757d;
}
.toggle-button.off:hover {
background: #5a6268;
}
.brightness-control {
margin-top: 1rem;
}
.brightness-label {
font-size: 0.875rem;
color: #666;
display: block;
margin-bottom: 0.5rem;
}
.brightness-slider {
width: 100%;
height: 8px;
border-radius: 4px;
background: #ddd;
outline: none;
-webkit-appearance: none;
}
.brightness-slider::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 20px;
height: 20px;
border-radius: 50%;
background: #667eea;
cursor: pointer;
}
.brightness-slider::-moz-range-thumb {
width: 20px;
height: 20px;
border-radius: 50%;
background: #667eea;
cursor: pointer;
border: none;
}
/* Thermostat styles */
.thermostat-display {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
margin: 1rem 0;
}
.temp-reading {
background: #f8f9fa;
border-radius: 8px;
padding: 1rem;
text-align: center;
}
.temp-label {
font-size: 0.75rem;
color: #666;
text-transform: uppercase;
margin-bottom: 0.25rem;
}
.temp-value {
font-size: 2rem;
font-weight: 700;
color: #333;
}
.temp-unit {
font-size: 1rem;
color: #999;
}
/* Thermostat Slider Styles */
.thermostat-slider-control {
margin: 1rem 0;
}
.thermostat-slider-label {
font-size: 0.875rem;
color: #666;
display: block;
margin-bottom: 0.5rem;
}
.thermostat-slider {
width: 100%;
height: 8px;
border-radius: 4px;
background: linear-gradient(to right, #667eea 0%, #764ba2 100%);
outline: none;
-webkit-appearance: none;
appearance: none;
cursor: pointer;
}
.thermostat-slider::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 24px;
height: 24px;
border-radius: 50%;
background: white;
border: 3px solid #667eea;
cursor: pointer;
box-shadow: 0 2px 4px rgba(0,0,0,0.2);
}
.thermostat-slider::-moz-range-thumb {
width: 24px;
height: 24px;
border-radius: 50%;
background: white;
border: 3px solid #667eea;
cursor: pointer;
box-shadow: 0 2px 4px rgba(0,0,0,0.2);
}
.thermostat-slider-range {
display: flex;
justify-content: space-between;
margin-top: 0.25rem;
font-size: 0.75rem;
color: #999;
}
/* Contact Sensor Styles */
.contact-status {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 1rem;
background: #f8f9fa;
border-radius: 8px;
margin: 1rem 0;
}
.contact-badge {
padding: 0.5rem 1rem;
border-radius: 6px;
font-weight: 600;
font-size: 0.875rem;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.contact-badge.open {
background: #dc3545;
color: white;
}
.contact-badge.closed {
background: #28a745;
color: white;
}
.contact-info {
font-size: 0.75rem;
color: #999;
margin-top: 1rem;
padding: 0.5rem;
background: #f8f9fa;
border-radius: 4px;
text-align: center;
}
/* Temperature & Humidity Sensor Styles */
.temp-humidity-display {
padding: 1rem;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: 12px;
color: white;
margin: 1rem 0;
}
.temp-humidity-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.75rem 0;
}
.temp-humidity-row:not(:last-child) {
border-bottom: 1px solid rgba(255, 255, 255, 0.2);
}
.temp-humidity-label {
font-size: 0.875rem;
font-weight: 500;
opacity: 0.9;
}
.temp-humidity-value {
font-size: 1.5rem;
font-weight: 700;
letter-spacing: -0.5px;
}
.temp-humidity-battery {
font-size: 0.75rem;
color: rgba(255, 255, 255, 0.8);
text-align: center;
padding: 0.5rem;
margin-top: 0.5rem;
background: rgba(0, 0, 0, 0.1);
border-radius: 6px;
}
.temp-humidity-info {
font-size: 0.75rem;
color: #999;
margin-top: 1rem;
padding: 0.5rem;
background: #f8f9fa;
border-radius: 4px;
text-align: center;
}
/* Groups Section Styles */
.groups-section .devices {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 1rem;
}
.group-card {
background: white;
border-radius: 12px;
padding: 1.25rem;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.08);
transition: all 0.3s;
}
.group-card:hover {
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.12);
transform: translateY(-2px);
}
.group-card-header {
margin-bottom: 1rem;
}
.group-card-title {
font-size: 1.125rem;
font-weight: 600;
color: #333;
margin-bottom: 0.25rem;
}
.group-card-subtitle {
font-size: 0.875rem;
color: #666;
}
.group-card-actions {
display: flex;
gap: 0.5rem;
}
.group-button {
flex: 1;
padding: 0.75rem;
border: none;
border-radius: 8px;
font-size: 0.875rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
min-height: 44px;
position: relative;
}
.group-button.on {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
}
.group-button.on:hover {
background: linear-gradient(135deg, #5568d3 0%, #653a8e 100%);
}
.group-button.off {
background: #f1f3f5;
color: #495057;
}
.group-button.off:hover {
background: #e9ecef;
}
.group-button:active {
transform: scale(0.95);
}
.group-button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.group-button .spinner {
display: inline-block;
width: 14px;
height: 14px;
border: 2px solid rgba(255, 255, 255, 0.3);
border-top-color: white;
border-radius: 50%;
animation: spin 0.6s linear infinite;
}
/* Scenes Section Styles */
.scenes-section .devices {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 1rem;
}
.scene-button {
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
color: white;
border: none;
border-radius: 12px;
padding: 1.25rem;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: all 0.3s;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
min-height: 80px;
display: flex;
align-items: center;
justify-content: center;
position: relative;
}
.scene-button:hover {
background: linear-gradient(135deg, #e082ea 0%, #e4465b 100%);
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
transform: translateY(-2px);
}
.scene-button:active {
transform: translateY(0) scale(0.95);
}
.scene-button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.scene-button .spinner {
display: inline-block;
width: 16px;
height: 16px;
border: 2px solid rgba(255, 255, 255, 0.3);
border-top-color: white;
border-radius: 50%;
animation: spin 0.6s linear infinite;
margin-left: 0.5rem;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* Toast Notification */
.toast {
position: fixed;
bottom: 2rem;
right: 2rem;
background: white;
padding: 1rem 1.5rem;
border-radius: 8px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.2);
display: flex;
align-items: center;
gap: 0.75rem;
z-index: 1000;
animation: slideIn 0.3s ease;
max-width: 400px;
}
@keyframes slideIn {
from {
transform: translateX(100%);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
.toast.success {
border-left: 4px solid #51cf66;
}
.toast.error {
border-left: 4px solid #ff6b6b;
}
.toast-icon {
font-size: 1.5rem;
}
.toast-message {
flex: 1;
color: #333;
font-size: 0.875rem;
}
.toast-close {
background: none;
border: none;
color: #999;
cursor: pointer;
font-size: 1.25rem;
padding: 0;
line-height: 1;
}
.events {
margin-top: 2rem;
background: white;
border-radius: 16px;
padding: 1.5rem;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);
}
.events h2 {
color: #333;
margin-bottom: 1rem;
font-size: 1.25rem;
}
.event-list {
max-height: 300px;
overflow-y: auto;
}
.event-item {
padding: 0.75rem;
border-left: 3px solid #667eea;
background: #f8f9fa;
margin-bottom: 0.5rem;
border-radius: 4px;
font-size: 0.875rem;
}
.event-time {
color: #666;
font-size: 0.75rem;
}
.event-data {
color: #333;
margin-top: 0.25rem;
font-family: 'Courier New', monospace;
}
.empty-state {
background: white;
border-radius: 16px;
padding: 3rem;
text-align: center;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);
}
.empty-state p {
color: #666;
margin-bottom: 0.5rem;
}
.hint {
font-size: 0.875rem;
color: #999;
}
/* Responsive Design */
@media (max-width: 768px) {
.container {
padding: 1rem;
}
header {
padding: 1rem;
}
.header-content h1 {
font-size: 1.5rem;
}
.header-content p {
font-size: 0.75rem;
}
.header-buttons {
gap: 0.5rem;
}
.refresh-btn, .collapse-all-btn {
width: 36px;
height: 36px;
font-size: 1.25rem;
}
.room {
padding: 1rem;
}
.room-header h2 {
font-size: 1.125rem;
}
.devices {
grid-template-columns: 1fr;
}
/* Groups responsive */
.groups-section .devices {
grid-template-columns: 1fr;
}
.group-card {
padding: 1rem;
}
.group-card-title {
font-size: 1rem;
}
/* Scenes responsive */
.scenes-section .devices {
grid-template-columns: 1fr;
}
.scene-button {
min-height: 60px;
font-size: 0.9375rem;
}
/* Toast responsive */
.toast {
bottom: 1rem;
right: 1rem;
left: 1rem;
max-width: none;
}
/* Thermostat responsive */
.thermostat-display {
grid-template-columns: 1fr;
gap: 0.75rem;
}
.temp-controls {
flex-direction: column;
}
.temp-button {
width: 100%;
}
}
@media (min-width: 769px) and (max-width: 1024px) {
.devices {
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
}
.groups-section .devices {
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
}
.scenes-section .devices {
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
}
}
</style>
</head>
<body>
<div class="container">
<header>
<div class="header-content">
<h1>🏠 Home Automation</h1>
<p>Realtime Status: <span class="status disconnected" id="connection-status">Verbinde...</span></p>
</div>
<div class="header-buttons">
<button class="refresh-btn" onclick="refreshPage()" title="Seite aktualisieren">
<span class="refresh-icon" id="refresh-icon"></span>
</button>
<button class="collapse-all-btn" onclick="toggleAllRooms()" title="Alle Räume ein-/ausklappen">
<span class="collapse-all-icon collapsed" id="collapse-all-icon"></span>
</button>
</div>
</header>
{% if rooms %}
{% for room in rooms %}
<section class="room">
<div class="room-header" onclick="toggleRoom('room-{{ loop.index }}')">
<h2 class="room-title">{{ room.name }}</h2>
<span class="room-toggle collapsed" id="toggle-room-{{ loop.index }}"></span>
</div>
<div class="room-content collapsed" id="room-{{ loop.index }}">
<div class="devices">
{% for device in room.devices %}
<div class="device-card" data-device-id="{{ device.device_id }}">
<div class="device-header">
<div class="device-name">{{ device.icon }} {{ device.title }}</div>
<div class="device-type">
{% if device.type == "light" %}
Light
{% if device.features.brightness %}• Dimmbar{% endif %}
{% elif device.type == "relay" %}
Relay
{% elif device.type == "thermostat" %}
Thermostat
{% elif device.type == "contact" or device.type == "contact_sensor" %}
Contact Sensor • Read-Only
{% else %}
{{ device.type or "Unknown" }}
{% endif %}
</div>
<div class="device-id">{{ device.device_id }}</div>
</div>
{% if device.type == "light" %}
<div class="device-state">
<span class="state-label">Status:</span>
<span class="state-value off" id="state-{{ device.device_id }}">off</span>
{% if device.features.brightness %}
<br>
<span class="state-label">Helligkeit:</span>
<span class="state-value" id="brightness-{{ device.device_id }}">50</span>%
{% endif %}
</div>
{% if device.features.power %}
<div class="controls">
<button
class="toggle-button off"
id="toggle-{{ device.device_id }}"
onclick="toggleDevice('{{ device.device_id }}')">
Einschalten
</button>
{% if device.features.brightness %}
<div class="brightness-control">
<label for="brightness-slider-{{ device.device_id }}" class="brightness-label">
Helligkeit: <span id="brightness-value-{{ device.device_id }}">50</span>%
</label>
<input
type="range"
class="brightness-slider"
id="brightness-slider-{{ device.device_id }}"
min="0"
max="100"
value="50"
oninput="updateBrightnessValue('{{ device.device_id }}', this.value)"
onchange="setBrightness('{{ device.device_id }}', this.value)">
</div>
{% endif %}
</div>
{% endif %}
{% elif device.type == "relay" %}
<div class="device-state">
<span class="state-label">Status:</span>
<span class="state-value off" id="state-{{ device.device_id }}">off</span>
</div>
<div class="controls">
<button
class="toggle-button off"
id="toggle-{{ device.device_id }}"
onclick="toggleDevice('{{ device.device_id }}')">
Einschalten
</button>
</div>
{% elif device.type == "thermostat" %}
<div class="thermostat-display">
<div class="temp-reading">
<div class="temp-label">Ist</div>
<div class="temp-value">
<span id="state-{{ device.device_id }}-current">--</span>
<span class="temp-unit">°C</span>
</div>
</div>
<div class="temp-reading">
<div class="temp-label">Soll</div>
<div class="temp-value">
<span id="state-{{ device.device_id }}-target">21.0</span>
<span class="temp-unit">°C</span>
</div>
</div>
</div>
<div class="thermostat-slider-control">
<label for="slider-{{ device.device_id }}" class="thermostat-slider-label">
🎯 Zieltemperatur: <span id="thermostat-slider-value-{{ device.device_id }}">21.0</span>°C
</label>
<input type="range"
min="5"
max="30"
step="0.5"
value="21.0"
class="thermostat-slider"
id="slider-{{ device.device_id }}"
oninput="updateThermostatSliderValue('{{ device.device_id }}', this.value)"
onchange="setThermostatTarget('{{ device.device_id }}', this.value)">
<div class="thermostat-slider-range">
<span>5°C</span>
<span>30°C</span>
</div>
</div>
{% elif device.type == "contact" or device.type == "contact_sensor" %}
<div class="contact-status">
<span class="contact-badge closed" id="state-{{ device.device_id }}">
Geschlossen
</span>
</div>
<div class="contact-info">
🔒 Nur-Lesen Gerät • Keine Steuerung möglich
</div>
{% elif device.type == "temp_humidity" or device.type == "temp_humidity_sensor" %}
<div class="temp-humidity-display">
<div class="temp-humidity-row">
<span class="temp-humidity-label">🌡️ Temperatur:</span>
<span class="temp-humidity-value" id="th-{{ device.device_id }}-t">--</span>
<span style="font-size: 1.25rem; font-weight: 600;">°C</span>
</div>
<div class="temp-humidity-row">
<span class="temp-humidity-label">💧 Luftfeuchte:</span>
<span class="temp-humidity-value" id="th-{{ device.device_id }}-h">--</span>
<span style="font-size: 1.25rem; font-weight: 600;">%</span>
</div>
</div>
<div class="temp-humidity-battery" id="th-{{ device.device_id }}-battery" style="display: none;">
🔋 <span id="th-{{ device.device_id }}-battery-value">--</span>%
</div>
<div class="temp-humidity-info">
🔒 Nur-Lesen Gerät • Keine Steuerung möglich
</div>
{% endif %}
</div>
{% endfor %}
</div>
</div>
</section>
{% endfor %}
{% else %}
<div class="empty-state">
<p>Keine Räume oder Geräte konfiguriert.</p>
<p class="hint">Prüfe config/layout.yaml und das API-Gateway.</p>
</div>
{% endif %}
<!-- Groups Section -->
<section class="room groups-section">
<div class="room-header" onclick="toggleRoom('groups-content')">
<div class="room-title">
<h2>🎛️ Gruppen</h2>
<span class="device-count" id="groups-count">Lädt...</span>
</div>
<span class="room-toggle collapsed" id="toggle-groups-content"></span>
</div>
<div class="room-content collapsed" id="groups-content">
<div class="devices" id="groups-container">
<p style="color: #666;">Lade Gruppen...</p>
</div>
</div>
</section>
<!-- Scenes Section -->
<section class="room scenes-section">
<div class="room-header" onclick="toggleRoom('scenes-content')">
<div class="room-title">
<h2>🎬 Szenen</h2>
<span class="device-count" id="scenes-count">Lädt...</span>
</div>
<span class="room-toggle collapsed" id="toggle-scenes-content"></span>
</div>
<div class="room-content collapsed" id="scenes-content">
<div class="devices" id="scenes-container">
<p style="color: #666;">Lade Szenen...</p>
</div>
</div>
</section>
<div class="events">
<h2>📡 Realtime Events</h2>
<div class="event-list" id="event-list">
<p style="color: #666; font-size: 0.875rem;">Warte auf Events...</p>
</div>
</div>
</div>
<script>
// Toggle room visibility
function toggleRoom(roomId) {
const content = document.getElementById(roomId);
const toggle = document.getElementById(`toggle-${roomId}`);
if (content && toggle) {
content.classList.toggle('collapsed');
toggle.classList.toggle('collapsed');
}
}
// Refresh page with animation
function refreshPage() {
const icon = document.getElementById('refresh-icon');
icon.classList.add('spinning');
// Reload page after brief animation
setTimeout(() => {
window.location.reload();
}, 300);
}
// Toggle all rooms
function toggleAllRooms() {
const allContents = document.querySelectorAll('.room-content');
const allToggles = document.querySelectorAll('.room-toggle');
const buttonIcon = document.getElementById('collapse-all-icon');
// Check if any room is expanded
const anyExpanded = Array.from(allContents).some(content => !content.classList.contains('collapsed'));
if (anyExpanded) {
// Collapse all
allContents.forEach(content => content.classList.add('collapsed'));
allToggles.forEach(toggle => toggle.classList.add('collapsed'));
buttonIcon.classList.add('collapsed');
} else {
// Expand all
allContents.forEach(content => content.classList.remove('collapsed'));
allToggles.forEach(toggle => toggle.classList.remove('collapsed'));
buttonIcon.classList.remove('collapsed');
}
}
// Set room icons based on room name
document.addEventListener('DOMContentLoaded', () => {
// Only select room titles that are <h2> elements (exclude Groups/Scenes sections)
const roomTitles = document.querySelectorAll('.room-title:not(.groups-section .room-title):not(.scenes-section .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}`;
});
});
// Clean up SSE connection before page unload
window.addEventListener('beforeunload', () => {
if (eventSource) {
console.log('Closing SSE connection before unload');
eventSource.close();
eventSource = null;
}
});
// API_BASE injected from backend (supports Docker/K8s environments)
window.API_BASE = '{{ api_base }}';
window.RUNTIME_CONFIG = window.RUNTIME_CONFIG || {};
// Helper function to construct API URLs
function api(url) {
return `${window.API_BASE}${url}`;
}
// iOS/Safari Polyfill laden (nur wenn nötig)
(function() {
var isIOS = /iP(hone|od|ad)/.test(navigator.platform) ||
(navigator.userAgent.includes("Mac") && "ontouchend" in document);
if (isIOS && typeof window.EventSourcePolyfill === "undefined") {
var s = document.createElement("script");
s.src = "https://cdn.jsdelivr.net/npm/event-source-polyfill@1.0.31/src/eventsource.min.js";
s.onerror = function() {
console.warn("EventSource polyfill konnte nicht geladen werden");
};
document.head.appendChild(s);
}
})();
let eventSource = null;
let currentState = {};
let thermostatTargets = {};
let deviceTypes = {};
// Initialize device states
{% for room in rooms %}
{% for device in room.devices %}
deviceTypes['{{ device.device_id }}'] = '{{ device.type }}';
{% if device.type == "light" or device.type == "relay" %}
currentState['{{ device.device_id }}'] = 'off';
{% elif device.type == "thermostat" %}
thermostatTargets['{{ device.device_id }}'] = 21.0;
{% endif %}
{% endfor %}
{% endfor %}
// Toggle device state
async function toggleDevice(deviceId) {
const newState = currentState[deviceId] === 'on' ? 'off' : 'on';
const deviceType = deviceTypes[deviceId] || 'light';
try {
const response = await fetch(api(`/devices/${deviceId}/set`), {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
type: deviceType,
payload: {
power: newState
}
})
});
if (response.ok) {
console.log(`Sent ${newState} command to ${deviceId}`);
addEvent({
action: 'command_sent',
device_id: deviceId,
state: newState
});
}
} catch (error) {
console.error('Failed to toggle device:', error);
}
}
// Update brightness value display
function updateBrightnessValue(deviceId, value) {
const valueSpan = document.getElementById(`brightness-value-${deviceId}`);
if (valueSpan) {
valueSpan.textContent = value;
}
}
// Set brightness
async function setBrightness(deviceId, brightness) {
try {
const response = await fetch(api(`/devices/${deviceId}/set`), {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
type: 'light',
payload: {
brightness: parseInt(brightness)
}
})
});
if (response.ok) {
console.log(`Sent brightness ${brightness} to ${deviceId}`);
addEvent({
action: 'brightness_set',
device_id: deviceId,
brightness: parseInt(brightness)
});
}
} catch (error) {
console.error('Failed to set brightness:', error);
}
}
// Adjust thermostat target temperature
async function adjustTarget(deviceId, delta) {
const currentTarget = thermostatTargets[deviceId] || 21.0;
const newTarget = Math.max(5.0, Math.min(30.0, currentTarget + delta));
try {
const response = await fetch(api(`/devices/${deviceId}/set`), {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
type: 'thermostat',
payload: {
target: newTarget
}
})
});
if (response.ok) {
console.log(`Sent target ${newTarget} to ${deviceId}`);
addEvent({
action: 'target_adjusted',
device_id: deviceId,
target: newTarget
});
}
} catch (error) {
console.error('Failed to adjust target:', error);
}
}
// Update device UI
function updateDeviceUI(deviceId, power, brightness) {
currentState[deviceId] = power;
const stateSpan = document.getElementById(`state-${deviceId}`);
const toggleButton = document.getElementById(`toggle-${deviceId}`);
if (stateSpan) {
stateSpan.textContent = power;
stateSpan.className = `state-value ${power}`;
}
if (toggleButton) {
if (power === 'on') {
toggleButton.textContent = 'Ausschalten';
toggleButton.className = 'toggle-button on';
} else {
toggleButton.textContent = 'Einschalten';
toggleButton.className = 'toggle-button off';
}
// Force reflow for iOS Safari
void toggleButton.offsetHeight;
}
// Update brightness display and slider
if (brightness !== undefined) {
const brightnessSpan = document.getElementById(`brightness-${deviceId}`);
const brightnessValue = document.getElementById(`brightness-value-${deviceId}`);
const brightnessSlider = document.getElementById(`brightness-slider-${deviceId}`);
if (brightnessSpan) {
brightnessSpan.textContent = brightness;
}
if (brightnessValue) {
brightnessValue.textContent = brightness;
}
if (brightnessSlider) {
brightnessSlider.value = brightness;
}
}
}
// Update thermostat UI
function updateThermostatUI(deviceId, current, target, mode) {
const currentSpan = document.getElementById(`state-${deviceId}-current`);
const targetSpan = document.getElementById(`state-${deviceId}-target`);
const slider = document.getElementById(`slider-${deviceId}`);
const sliderValueSpan = document.getElementById(`thermostat-slider-value-${deviceId}`);
if (current !== undefined && currentSpan) {
currentSpan.textContent = current.toFixed(1);
}
if (target !== undefined) {
if (targetSpan) {
targetSpan.textContent = target.toFixed(1);
}
// Sync slider with actual state
if (slider) {
slider.value = target;
}
// Sync slider value display
if (sliderValueSpan) {
sliderValueSpan.textContent = target.toFixed(1);
}
thermostatTargets[deviceId] = target;
}
}
// Update contact sensor UI
function updateContactUI(deviceId, contactState) {
const badge = document.getElementById(`state-${deviceId}`);
if (!badge) {
console.warn(`No contact badge found for device ${deviceId}`);
return;
}
// contactState is either "open" or "closed"
if (contactState === "open") {
badge.textContent = "Geöffnet";
badge.className = "contact-badge open";
} else if (contactState === "closed") {
badge.textContent = "Geschlossen";
badge.className = "contact-badge closed";
}
}
// Update temperature & humidity sensor UI
function updateTempHumidityUI(deviceId, payload) {
const tempSpan = document.getElementById(`th-${deviceId}-t`);
const humiditySpan = document.getElementById(`th-${deviceId}-h`);
const batteryDiv = document.getElementById(`th-${deviceId}-battery`);
const batteryValueSpan = document.getElementById(`th-${deviceId}-battery-value`);
if (!tempSpan || !humiditySpan) {
console.warn(`No temp/humidity elements found for device ${deviceId}`);
return;
}
// Update temperature (rounded to 1 decimal)
if (payload.temperature !== undefined && payload.temperature !== null) {
tempSpan.textContent = payload.temperature.toFixed(1);
}
// Update humidity (rounded to 0-1 decimals)
if (payload.humidity !== undefined && payload.humidity !== null) {
// Round to 1 decimal if has decimals, otherwise integer
const humidity = payload.humidity;
humiditySpan.textContent = (humidity % 1 === 0) ? humidity.toFixed(0) : humidity.toFixed(1);
}
// Update battery if present
if (payload.battery !== undefined && payload.battery !== null && batteryDiv && batteryValueSpan) {
batteryValueSpan.textContent = payload.battery;
batteryDiv.style.display = 'block';
} else if (batteryDiv) {
batteryDiv.style.display = 'none';
}
}
// Add event to list
function addEvent(event) {
const eventList = document.getElementById('event-list');
// Clear placeholder
if (eventList.children.length === 1 && eventList.children[0].tagName === 'P') {
eventList.innerHTML = '';
}
const eventItem = document.createElement('div');
eventItem.className = 'event-item';
const now = new Date().toLocaleTimeString('de-DE');
eventItem.innerHTML = `
<div class="event-time">${now}</div>
<div class="event-data">${JSON.stringify(event, null, 2)}</div>
`;
eventList.insertBefore(eventItem, eventList.firstChild);
// Keep only last 10 events
while (eventList.children.length > 10) {
eventList.removeChild(eventList.lastChild);
}
}
// Safari/iOS-kompatibler SSE Client mit Auto-Reconnect
let reconnectDelay = 2500;
let reconnectTimer = null;
// Global handleSSE function für SSE-Nachrichten
window.handleSSE = function(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.target !== undefined || data.payload.current !== undefined) {
if (data.payload.target !== undefined) {
thermostatTargets[data.device_id] = data.payload.target;
}
updateThermostatUI(
data.device_id,
data.payload.current,
data.payload.target
);
}
// Check if it's a contact sensor
if (data.payload.contact !== undefined) {
updateContactUI(data.device_id, data.payload.contact);
}
// Check if it's a temp/humidity sensor
if (data.payload.temperature !== undefined || data.payload.humidity !== undefined) {
updateTempHumidityUI(data.device_id, data.payload);
}
}
};
function cleanupSSE() {
if (eventSource) {
try {
eventSource.close();
} catch(e) {
console.error('Error closing EventSource:', e);
}
eventSource = null;
}
if (reconnectTimer) {
clearTimeout(reconnectTimer);
reconnectTimer = null;
}
}
function scheduleReconnect() {
if (reconnectTimer) return;
console.log(`Reconnecting in ${reconnectDelay}ms...`);
reconnectTimer = setTimeout(() => {
reconnectTimer = null;
connectSSE();
// Backoff bis 10s
reconnectDelay = Math.min(reconnectDelay * 2, 10000);
}, reconnectDelay);
}
function connectSSE() {
cleanupSSE();
const REALTIME_URL = (window.RUNTIME_CONFIG && window.RUNTIME_CONFIG.REALTIME_URL)
? window.RUNTIME_CONFIG.REALTIME_URL
: api('/realtime');
console.log('Connecting to SSE:', REALTIME_URL);
try {
// Verwende Polyfill wenn verfügbar, sonst native EventSource
const EventSourceImpl = window.EventSourcePolyfill || window.EventSource;
eventSource = new EventSourceImpl(REALTIME_URL, {
withCredentials: false
});
eventSource.onopen = function() {
console.log('SSE connected successfully');
reconnectDelay = 2500; // Reset backoff
document.getElementById('connection-status').textContent = 'Verbunden';
document.getElementById('connection-status').className = 'status connected';
};
eventSource.onmessage = function(evt) {
if (!evt || !evt.data) return;
// Heartbeats beginnen mit ":" -> ignorieren
if (typeof evt.data === "string" && evt.data.charAt(0) === ":") {
return;
}
try {
const data = JSON.parse(evt.data);
if (window.handleSSE) {
window.handleSSE(data);
}
} catch (e) {
console.error('Error parsing SSE message:', e);
}
};
eventSource.onerror = function(error) {
console.error('SSE error:', error, 'readyState:', eventSource?.readyState);
document.getElementById('connection-status').textContent = 'Getrennt';
document.getElementById('connection-status').className = 'status disconnected';
// Safari/iOS verliert Netz beim App-Switch: ruhig reconnecten
scheduleReconnect();
};
} catch (error) {
console.error('Failed to create EventSource:', error);
document.getElementById('connection-status').textContent = 'Getrennt';
document.getElementById('connection-status').className = 'status disconnected';
scheduleReconnect();
}
}
// Visibility-Change Handler für iOS App-Switch
document.addEventListener('visibilitychange', function() {
if (!document.hidden) {
// Wenn wieder sichtbar & keine offene Verbindung: verbinden
if (!eventSource || eventSource.readyState !== 1) {
console.log('Page visible again, reconnecting SSE...');
connectSSE();
}
}
});
// Start SSE connection
connectSSE();
// Load initial device states
async function loadDevices() {
try {
const response = await fetch(api('/devices/states'));
const states = await response.json();
console.log('Loaded initial device states:', states);
// Update UI with initial states
for (const [deviceId, state] of Object.entries(states)) {
if (state.power !== undefined) {
// It's a light
currentState[deviceId] = state.power;
updateDeviceUI(deviceId, state.power, state.brightness);
} else if (state.target !== undefined) {
// It's a thermostat
if (state.target) thermostatTargets[deviceId] = state.target;
updateThermostatUI(deviceId, state.current, state.target);
} else if (state.contact !== undefined) {
// It's a contact sensor
updateContactUI(deviceId, state.contact);
} else if (state.temperature !== undefined || state.humidity !== undefined) {
// It's a temp/humidity sensor
updateTempHumidityUI(deviceId, state);
}
}
} catch (error) {
console.error('Failed to load initial device states:', error);
}
}
// Load initial states before connecting SSE
loadDevices().then(() => {
console.log('Initial states loaded, now connecting SSE...');
});
// ===== GROUPS & SCENES FUNCTIONALITY =====
// Show toast notification
function showToast(message, type = 'success') {
// Remove existing toasts
document.querySelectorAll('.toast').forEach(t => t.remove());
const toast = document.createElement('div');
toast.className = `toast ${type}`;
toast.innerHTML = `
<span class="toast-icon">${type === 'success' ? '✓' : '✗'}</span>
<span class="toast-message">${message}</span>
<button class="toast-close" onclick="this.parentElement.remove()">×</button>
`;
document.body.appendChild(toast);
// Auto-remove after 4 seconds
setTimeout(() => {
toast.style.animation = 'slideIn 0.3s ease reverse';
setTimeout(() => toast.remove(), 300);
}, 4000);
}
// Load and render groups
async function loadGroups() {
try {
const response = await fetch(api('/groups'));
if (!response.ok) throw new Error('Failed to load groups');
const groups = await response.json();
const container = document.getElementById('groups-container');
const countSpan = document.getElementById('groups-count');
if (groups.length === 0) {
container.innerHTML = '<p style="color: #666;">Keine Gruppen konfiguriert.</p>';
countSpan.textContent = '';
return;
}
countSpan.textContent = '';
container.innerHTML = groups.map(group => `
<div class="group-card">
<div class="group-card-header">
<div class="group-card-title">${group.name}</div>
<div class="group-card-subtitle">${group.device_count} ${group.device_count === 1 ? 'Gerät' : 'Geräte'}</div>
</div>
${group.capabilities.brightness ? `
<div class="brightness-control">
<label class="brightness-label">
<span>🔆 Helligkeit</span>
<span class="brightness-value" id="group-brightness-${group.id}">50%</span>
</label>
<input type="range"
min="0"
max="100"
value="50"
class="brightness-slider"
id="slider-group-${group.id}"
oninput="updateGroupBrightnessDisplay('${group.id}', this.value)"
onchange="setGroupBrightness('${group.id}', this.value)">
</div>
` : ''}
<div class="group-card-actions">
<button class="group-button on" onclick="setGroup('${group.id}', 'on', this)">
Alle An
</button>
<button class="group-button off" onclick="setGroup('${group.id}', 'off', this)">
Alle Aus
</button>
</div>
</div>
`).join('');
console.log(`Loaded ${groups.length} groups`);
} catch (error) {
console.error('Failed to load groups:', error);
const container = document.getElementById('groups-container');
container.innerHTML = '<p style="color: #999;">Fehler beim Laden der Gruppen</p>';
showToast('Fehler beim Laden der Gruppen', 'error');
}
}
// Update group brightness display value
function updateGroupBrightnessDisplay(groupId, value) {
const display = document.getElementById(`group-brightness-${groupId}`);
if (display) {
display.textContent = `${value}%`;
}
}
// Set group brightness immediately when slider changes
async function setGroupBrightness(groupId, brightness) {
try {
const response = await fetch(api(`/groups/${groupId}/set`), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
action: {
type: 'power',
payload: {
power: 'on',
brightness: parseInt(brightness)
}
}
})
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || 'Request failed');
}
const result = await response.json();
const publishedCount = result.execution_plan.filter(p => p.status === 'published').length;
console.log(`Group ${groupId} brightness set to ${brightness}%: ${publishedCount} devices`);
addEvent({
action: 'group_brightness',
group_id: groupId,
brightness: brightness,
device_count: publishedCount
});
} catch (error) {
console.error('Failed to set group brightness:', error);
showToast(`Fehler beim Setzen der Helligkeit: ${error.message}`, 'error');
}
}
// Update thermostat slider value display while dragging
function updateThermostatSliderValue(deviceId, value) {
const valueSpan = document.getElementById(`thermostat-slider-value-${deviceId}`);
if (valueSpan) {
valueSpan.textContent = parseFloat(value).toFixed(1);
}
}
// Set thermostat target temperature when slider is released
async function setThermostatTarget(deviceId, value) {
try {
const targetTemp = parseFloat(value);
const response = await fetch(api(`/devices/${deviceId}/set`), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
type: 'thermostat',
payload: {
target: targetTemp
}
})
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || 'Request failed');
}
console.log(`Thermostat ${deviceId} target set to ${targetTemp}°C`);
addEvent({
action: 'thermostat_set',
device_id: deviceId,
target_temperature: targetTemp
});
} catch (error) {
console.error('Failed to set thermostat target:', error);
showToast(`Fehler beim Setzen der Zieltemperatur: ${error.message}`, 'error');
}
}
// Execute group action
async function setGroup(groupId, power, buttonElement) {
const allButtons = buttonElement.parentElement.querySelectorAll('button');
allButtons.forEach(btn => btn.disabled = true);
const originalHTML = buttonElement.innerHTML;
buttonElement.innerHTML = '<span class="spinner"></span>';
// Get brightness value if slider exists
const slider = document.getElementById(`slider-group-${groupId}`);
const brightness = slider ? parseInt(slider.value) : null;
try {
const payload = { power };
if (brightness !== null && power === 'on') {
payload.brightness = brightness;
}
const response = await fetch(api(`/groups/${groupId}/set`), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
action: {
type: 'power',
payload: payload
}
})
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || 'Request failed');
}
const result = await response.json();
const publishedCount = result.execution_plan.filter(p => p.status === 'published').length;
showToast(`Gruppe ${power === 'on' ? 'eingeschaltet' : 'ausgeschaltet'}: ${publishedCount} Geräte`, 'success');
console.log(`Group ${groupId} set to ${power}:`, result);
addEvent({
action: 'group_set',
group_id: groupId,
power: power,
device_count: publishedCount
});
} catch (error) {
console.error('Failed to set group:', error);
showToast(`Fehler: ${error.message}`, 'error');
} finally {
buttonElement.innerHTML = originalHTML;
allButtons.forEach(btn => btn.disabled = false);
}
}
// Load and render scenes
async function loadScenes() {
try {
const response = await fetch(api('/scenes'));
if (!response.ok) throw new Error('Failed to load scenes');
const scenes = await response.json();
const container = document.getElementById('scenes-container');
const countSpan = document.getElementById('scenes-count');
if (scenes.length === 0) {
container.innerHTML = '<p style="color: #666;">Keine Szenen konfiguriert.</p>';
countSpan.textContent = '';
return;
}
countSpan.textContent = '';
container.innerHTML = scenes.map(scene => `
<button class="scene-button" onclick="runScene('${scene.id}', this)">
${scene.name}
</button>
`).join('');
console.log(`Loaded ${scenes.length} scenes`);
} catch (error) {
console.error('Failed to load scenes:', error);
const container = document.getElementById('scenes-container');
container.innerHTML = '<p style="color: #999;">Fehler beim Laden der Szenen</p>';
showToast('Fehler beim Laden der Szenen', 'error');
}
}
// Execute scene
async function runScene(sceneId, buttonElement) {
buttonElement.disabled = true;
const originalHTML = buttonElement.innerHTML;
buttonElement.innerHTML = `${originalHTML} <span class="spinner"></span>`;
try {
const response = await fetch(api(`/scenes/${sceneId}/run`), {
method: 'POST',
headers: { 'Content-Type': 'application/json' }
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || 'Request failed');
}
const result = await response.json();
const totalPublished = result.steps.reduce((sum, step) =>
sum + step.devices.filter(d => d.status === 'published').length, 0
);
showToast(`Szene ausgeführt: ${totalPublished} Aktionen`, 'success');
console.log(`Scene ${sceneId} executed:`, result);
addEvent({
action: 'scene_run',
scene_id: sceneId,
steps: result.steps.length,
total_actions: totalPublished
});
} catch (error) {
console.error('Failed to run scene:', error);
showToast(`Fehler: ${error.message}`, 'error');
} finally {
buttonElement.innerHTML = originalHTML;
buttonElement.disabled = false;
}
}
// Load groups and scenes on page load
document.addEventListener('DOMContentLoaded', () => {
loadGroups();
loadScenes();
});
</script>
</body>
</html>