1830 lines
64 KiB
HTML
1830 lines
64 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="de">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>Rauchmelder - Einstellungen</title>
|
||
<style>
|
||
* {
|
||
margin: 0;
|
||
padding: 0;
|
||
box-sizing: border-box;
|
||
}
|
||
|
||
body {
|
||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
|
||
min-height: 100vh;
|
||
color: #fff;
|
||
}
|
||
|
||
/* iframe-Modus: Navigation ausblenden */
|
||
body.iframe-mode .nav {
|
||
display: none;
|
||
}
|
||
|
||
body.iframe-mode .container {
|
||
padding-top: 15px;
|
||
}
|
||
|
||
body.iframe-mode h1 {
|
||
font-size: 22px;
|
||
}
|
||
|
||
.nav {
|
||
background: #0d0d1a;
|
||
padding: 15px 20px;
|
||
display: flex;
|
||
gap: 20px;
|
||
border-bottom: 1px solid #2a2a4a;
|
||
}
|
||
|
||
.nav a {
|
||
color: #888;
|
||
text-decoration: none;
|
||
padding: 10px 20px;
|
||
border-radius: 8px;
|
||
transition: all 0.2s;
|
||
}
|
||
|
||
.nav a:hover {
|
||
background: #2a2a4a;
|
||
color: #fff;
|
||
}
|
||
|
||
.nav a.active {
|
||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||
color: #fff;
|
||
}
|
||
|
||
.container {
|
||
max-width: 900px;
|
||
margin: 0 auto;
|
||
padding: 30px 20px;
|
||
}
|
||
|
||
h1 {
|
||
margin-bottom: 10px;
|
||
font-size: 28px;
|
||
}
|
||
|
||
.subtitle {
|
||
color: #888;
|
||
margin-bottom: 30px;
|
||
}
|
||
|
||
.status-bar {
|
||
padding: 15px;
|
||
border-radius: 10px;
|
||
margin-bottom: 20px;
|
||
display: none;
|
||
}
|
||
|
||
.status-bar.success { background: #1b4332; display: block; }
|
||
.status-bar.error { background: #5c1a1a; display: block; }
|
||
.status-bar.info { background: #1a3a5c; display: block; }
|
||
|
||
/* ==================== GRUPPEN ==================== */
|
||
.group-container {
|
||
background: linear-gradient(135deg, #1e1e3f 0%, #252550 100%);
|
||
border-radius: 16px;
|
||
margin-bottom: 25px;
|
||
border: 2px solid #3a3a6a;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.group-header {
|
||
background: linear-gradient(135deg, #2a2a5a 0%, #3a3a7a 100%);
|
||
padding: 20px;
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
cursor: pointer;
|
||
transition: background 0.2s;
|
||
}
|
||
|
||
.group-header:hover {
|
||
background: linear-gradient(135deg, #3a3a6a 0%, #4a4a8a 100%);
|
||
}
|
||
|
||
.group-header-left {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 15px;
|
||
}
|
||
|
||
.group-icon {
|
||
font-size: 28px;
|
||
}
|
||
|
||
.group-name-display {
|
||
font-size: 20px;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.group-stats {
|
||
font-size: 13px;
|
||
color: #aaa;
|
||
margin-top: 4px;
|
||
}
|
||
|
||
.group-header-right {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
}
|
||
|
||
.group-toggle {
|
||
font-size: 20px;
|
||
transition: transform 0.3s;
|
||
color: #888;
|
||
}
|
||
|
||
.group-container.collapsed .group-toggle {
|
||
transform: rotate(-90deg);
|
||
}
|
||
|
||
.group-container.collapsed .group-content {
|
||
display: none;
|
||
}
|
||
|
||
.group-content {
|
||
padding: 20px;
|
||
}
|
||
|
||
.group-actions {
|
||
display: flex;
|
||
gap: 8px;
|
||
}
|
||
|
||
.group-action-btn {
|
||
background: transparent;
|
||
border: 1px solid #4a4a7a;
|
||
color: #aaa;
|
||
padding: 6px 10px;
|
||
border-radius: 6px;
|
||
cursor: pointer;
|
||
font-size: 12px;
|
||
transition: all 0.2s;
|
||
}
|
||
|
||
.group-action-btn:hover {
|
||
border-color: #667eea;
|
||
color: #667eea;
|
||
}
|
||
|
||
.group-action-btn.danger:hover {
|
||
border-color: #dc3545;
|
||
color: #dc3545;
|
||
}
|
||
|
||
/* Add Group Button */
|
||
.add-group-btn {
|
||
width: 100%;
|
||
padding: 20px;
|
||
background: transparent;
|
||
border: 3px dashed #3a3a6a;
|
||
border-radius: 16px;
|
||
color: #888;
|
||
font-size: 16px;
|
||
cursor: pointer;
|
||
transition: all 0.2s;
|
||
margin-bottom: 25px;
|
||
}
|
||
|
||
.add-group-btn:hover {
|
||
border-color: #667eea;
|
||
color: #667eea;
|
||
}
|
||
|
||
/* ==================== SECTIONS ==================== */
|
||
.section {
|
||
background: #2a2a4a;
|
||
border-radius: 12px;
|
||
padding: 25px;
|
||
margin-bottom: 20px;
|
||
border: 1px solid #3a3a5a;
|
||
}
|
||
|
||
.section-title {
|
||
font-size: 18px;
|
||
font-weight: 600;
|
||
margin-bottom: 20px;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
}
|
||
|
||
.form-group {
|
||
margin-bottom: 20px;
|
||
}
|
||
|
||
.form-group:last-child {
|
||
margin-bottom: 0;
|
||
}
|
||
|
||
label {
|
||
display: block;
|
||
color: #aaa;
|
||
font-size: 13px;
|
||
margin-bottom: 8px;
|
||
font-weight: 500;
|
||
}
|
||
|
||
input[type="text"],
|
||
input[type="number"],
|
||
select,
|
||
textarea {
|
||
width: 100%;
|
||
padding: 12px;
|
||
background: #1a1a2e;
|
||
border: 1px solid #3a3a5a;
|
||
border-radius: 8px;
|
||
color: #fff;
|
||
font-size: 14px;
|
||
font-family: inherit;
|
||
}
|
||
|
||
textarea {
|
||
min-height: 80px;
|
||
resize: vertical;
|
||
}
|
||
|
||
input:focus, select:focus, textarea:focus {
|
||
outline: none;
|
||
border-color: #667eea;
|
||
}
|
||
|
||
.hint {
|
||
font-size: 12px;
|
||
color: #666;
|
||
margin-top: 5px;
|
||
}
|
||
|
||
.placeholder-info {
|
||
background: #1a1a2e;
|
||
padding: 10px 15px;
|
||
border-radius: 6px;
|
||
font-size: 13px;
|
||
color: #888;
|
||
margin-top: 10px;
|
||
}
|
||
|
||
.placeholder-info code {
|
||
background: #2a2a4a;
|
||
padding: 2px 6px;
|
||
border-radius: 4px;
|
||
color: #667eea;
|
||
}
|
||
|
||
.range-group {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 15px;
|
||
}
|
||
|
||
.range-group input[type="range"] {
|
||
flex: 1;
|
||
-webkit-appearance: none;
|
||
height: 8px;
|
||
background: #1a1a2e;
|
||
border-radius: 4px;
|
||
border: 1px solid #3a3a5a;
|
||
}
|
||
|
||
.range-group input[type="range"]::-webkit-slider-thumb {
|
||
-webkit-appearance: none;
|
||
width: 20px;
|
||
height: 20px;
|
||
background: #667eea;
|
||
border-radius: 50%;
|
||
cursor: pointer;
|
||
}
|
||
|
||
.range-value {
|
||
background: #1a1a2e;
|
||
padding: 8px 15px;
|
||
border-radius: 6px;
|
||
min-width: 60px;
|
||
text-align: center;
|
||
font-weight: 600;
|
||
}
|
||
|
||
/* Cards für Rauchmelder und Geräte */
|
||
.card-list {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 15px;
|
||
}
|
||
|
||
.card {
|
||
background: #1a1a2e;
|
||
border-radius: 10px;
|
||
padding: 20px;
|
||
border: 1px solid #3a3a5a;
|
||
}
|
||
|
||
/* Status Badges */
|
||
.status-badges {
|
||
display: flex;
|
||
gap: 10px;
|
||
margin-top: 8px;
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
.status-badge {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 5px;
|
||
padding: 4px 10px;
|
||
border-radius: 12px;
|
||
font-size: 11px;
|
||
font-weight: 500;
|
||
}
|
||
|
||
.status-badge.smoke {
|
||
background: #1a3a1a;
|
||
color: #4ade80;
|
||
border: 1px solid #22c55e;
|
||
}
|
||
|
||
.status-badge.smoke.active {
|
||
background: #5c1a1a;
|
||
color: #ff6b6b;
|
||
border: 1px solid #dc3545;
|
||
animation: pulse-red 1.5s infinite;
|
||
}
|
||
|
||
.status-badge.siren {
|
||
background: #1a2a3a;
|
||
color: #60a5fa;
|
||
border: 1px solid #3b82f6;
|
||
}
|
||
|
||
.status-badge.siren.active {
|
||
background: #5c3a1a;
|
||
color: #ffa502;
|
||
border: 1px solid #ff9500;
|
||
animation: pulse-orange 1.5s infinite;
|
||
}
|
||
|
||
.status-badge.no-siren {
|
||
background: #2a2a3a;
|
||
color: #888;
|
||
border: 1px solid #444;
|
||
}
|
||
|
||
@keyframes pulse-red {
|
||
0%, 100% { opacity: 1; }
|
||
50% { opacity: 0.6; }
|
||
}
|
||
|
||
@keyframes pulse-orange {
|
||
0%, 100% { opacity: 1; }
|
||
50% { opacity: 0.6; }
|
||
}
|
||
|
||
.card-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
margin-bottom: 15px;
|
||
}
|
||
|
||
.card-title {
|
||
font-size: 16px;
|
||
font-weight: 600;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
}
|
||
|
||
.card-header-buttons {
|
||
display: flex;
|
||
gap: 8px;
|
||
align-items: center;
|
||
}
|
||
|
||
.test-btn {
|
||
background: #28a745;
|
||
color: white;
|
||
border: none;
|
||
padding: 6px 12px;
|
||
border-radius: 6px;
|
||
cursor: pointer;
|
||
font-size: 13px;
|
||
}
|
||
|
||
.test-btn:hover {
|
||
background: #218838;
|
||
}
|
||
|
||
.test-btn:disabled {
|
||
background: #555;
|
||
cursor: not-allowed;
|
||
opacity: 0.5;
|
||
}
|
||
|
||
.stop-device-btn {
|
||
background: linear-gradient(135deg, #ff6b6b 0%, #ee5a24 100%);
|
||
color: white;
|
||
border: none;
|
||
padding: 6px 12px;
|
||
border-radius: 6px;
|
||
cursor: pointer;
|
||
font-size: 13px;
|
||
transition: all 0.2s;
|
||
}
|
||
|
||
.stop-device-btn:hover {
|
||
transform: translateY(-1px);
|
||
box-shadow: 0 3px 10px rgba(238, 90, 36, 0.4);
|
||
}
|
||
|
||
.stop-device-btn:disabled {
|
||
background: #555;
|
||
cursor: not-allowed;
|
||
opacity: 0.5;
|
||
transform: none;
|
||
box-shadow: none;
|
||
}
|
||
|
||
.delete-btn {
|
||
background: #dc3545;
|
||
color: white;
|
||
border: none;
|
||
padding: 6px 12px;
|
||
border-radius: 6px;
|
||
cursor: pointer;
|
||
font-size: 13px;
|
||
}
|
||
|
||
.delete-btn:hover {
|
||
background: #c82333;
|
||
}
|
||
|
||
.card-fields {
|
||
display: grid;
|
||
grid-template-columns: 1fr 1fr;
|
||
gap: 15px;
|
||
}
|
||
|
||
.card-fields .full-width {
|
||
grid-column: 1 / -1;
|
||
}
|
||
|
||
.platform-selector {
|
||
display: flex;
|
||
gap: 10px;
|
||
}
|
||
|
||
.platform-btn {
|
||
flex: 1;
|
||
padding: 10px;
|
||
background: #2a2a4a;
|
||
border: 2px solid #3a3a5a;
|
||
border-radius: 6px;
|
||
color: #888;
|
||
cursor: pointer;
|
||
font-size: 13px;
|
||
transition: all 0.2s;
|
||
}
|
||
|
||
.platform-btn.active {
|
||
border-color: #667eea;
|
||
background: #3a3a6a;
|
||
color: #fff;
|
||
}
|
||
|
||
.add-btn {
|
||
width: 100%;
|
||
padding: 12px;
|
||
background: transparent;
|
||
border: 2px dashed #3a3a5a;
|
||
border-radius: 10px;
|
||
color: #888;
|
||
font-size: 14px;
|
||
cursor: pointer;
|
||
transition: all 0.2s;
|
||
margin-top: 15px;
|
||
}
|
||
|
||
.add-btn:hover {
|
||
border-color: #667eea;
|
||
color: #667eea;
|
||
}
|
||
|
||
/* Notfall-Buttons pro Gruppe */
|
||
.emergency-section {
|
||
background: linear-gradient(135deg, #2a1a1a 0%, #1a1a2e 100%);
|
||
border-color: #5c3a3a;
|
||
}
|
||
|
||
.emergency-buttons {
|
||
display: flex;
|
||
gap: 15px;
|
||
}
|
||
|
||
.emergency-btn {
|
||
flex: 1;
|
||
padding: 15px 20px;
|
||
border: none;
|
||
border-radius: 10px;
|
||
color: white;
|
||
font-size: 14px;
|
||
font-weight: 600;
|
||
cursor: pointer;
|
||
transition: transform 0.2s, box-shadow 0.2s;
|
||
}
|
||
|
||
.emergency-btn:hover {
|
||
transform: translateY(-2px);
|
||
}
|
||
|
||
.emergency-btn.stop-btn {
|
||
background: linear-gradient(135deg, #ff6b6b 0%, #ee5a24 100%);
|
||
}
|
||
|
||
.emergency-btn.stop-btn:hover {
|
||
box-shadow: 0 5px 20px rgba(238, 90, 36, 0.4);
|
||
}
|
||
|
||
.emergency-btn.siren-btn {
|
||
background: linear-gradient(135deg, #ffa502 0%, #ff6348 100%);
|
||
}
|
||
|
||
.emergency-btn.siren-btn:hover {
|
||
box-shadow: 0 5px 20px rgba(255, 165, 2, 0.4);
|
||
}
|
||
|
||
/* Bottom Actions */
|
||
.actions {
|
||
display: flex;
|
||
gap: 15px;
|
||
margin-top: 30px;
|
||
}
|
||
|
||
.save-btn {
|
||
flex: 1;
|
||
padding: 18px;
|
||
background: linear-gradient(135deg, #11998e 0%, #38ef7d 100%);
|
||
border: none;
|
||
border-radius: 10px;
|
||
color: white;
|
||
font-size: 16px;
|
||
font-weight: 600;
|
||
cursor: pointer;
|
||
transition: transform 0.2s, box-shadow 0.2s;
|
||
}
|
||
|
||
.save-btn:hover {
|
||
transform: translateY(-2px);
|
||
box-shadow: 0 5px 20px rgba(56, 239, 125, 0.4);
|
||
}
|
||
|
||
.refresh-page-btn {
|
||
flex: 1;
|
||
padding: 15px;
|
||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||
border: none;
|
||
border-radius: 10px;
|
||
color: white;
|
||
font-size: 14px;
|
||
font-weight: 600;
|
||
cursor: pointer;
|
||
transition: transform 0.2s, box-shadow 0.2s;
|
||
}
|
||
|
||
.refresh-page-btn:hover {
|
||
transform: translateY(-2px);
|
||
box-shadow: 0 5px 20px rgba(102, 126, 234, 0.4);
|
||
}
|
||
|
||
.empty-state {
|
||
text-align: center;
|
||
padding: 30px;
|
||
color: #666;
|
||
}
|
||
|
||
select {
|
||
appearance: none;
|
||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%23888' d='M6 8L1 3h10z'/%3E%3C/svg%3E");
|
||
background-repeat: no-repeat;
|
||
background-position: right 12px center;
|
||
padding-right: 36px;
|
||
cursor: pointer;
|
||
}
|
||
|
||
.refresh-btn {
|
||
background: transparent;
|
||
border: 1px solid #3a3a5a;
|
||
color: #888;
|
||
padding: 4px 8px;
|
||
border-radius: 4px;
|
||
cursor: pointer;
|
||
font-size: 11px;
|
||
margin-left: 8px;
|
||
}
|
||
|
||
.refresh-btn:hover {
|
||
border-color: #667eea;
|
||
color: #667eea;
|
||
}
|
||
|
||
/* Name Input im Header */
|
||
.group-name-input {
|
||
background: #1a1a2e;
|
||
border: 2px solid #667eea;
|
||
border-radius: 8px;
|
||
padding: 8px 12px;
|
||
color: #fff;
|
||
font-size: 18px;
|
||
font-weight: 600;
|
||
width: 200px;
|
||
}
|
||
|
||
.group-name-input:focus {
|
||
outline: none;
|
||
}
|
||
|
||
@media (max-width: 600px) {
|
||
.card-fields {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
.actions {
|
||
flex-direction: column;
|
||
}
|
||
.emergency-buttons {
|
||
flex-direction: column;
|
||
}
|
||
.group-header {
|
||
flex-direction: column;
|
||
gap: 15px;
|
||
}
|
||
.group-header-right {
|
||
width: 100%;
|
||
justify-content: flex-end;
|
||
}
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<nav class="nav">
|
||
<a href="#" class="active">⚙️ Einstellungen</a>
|
||
</nav>
|
||
|
||
<div class="container">
|
||
<h1>🔥 Rauchmelder Einstellungen</h1>
|
||
<p class="subtitle">Konfiguriere dein Rauchmelder-Benachrichtigungssystem</p>
|
||
|
||
<div id="statusBar" class="status-bar"></div>
|
||
|
||
<!-- Gruppen Container -->
|
||
<div id="groupsContainer"></div>
|
||
|
||
<!-- Gruppe hinzufügen Button -->
|
||
<button class="add-group-btn" onclick="addGroup()">
|
||
➕ Neue Gruppe hinzufügen
|
||
</button>
|
||
|
||
<!-- Actions -->
|
||
<div class="actions">
|
||
<button class="save-btn" onclick="saveConfig()">
|
||
💾 Alle Gruppen speichern
|
||
</button>
|
||
</div>
|
||
|
||
<!-- Refresh Button -->
|
||
<div class="actions" style="margin-top: 15px;">
|
||
<button class="refresh-page-btn" onclick="refreshPage()">
|
||
🔄 Seite aktualisieren
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<script>
|
||
// State - Neue Struktur mit Gruppen
|
||
let config = {
|
||
groups: []
|
||
};
|
||
|
||
// Entity-Listen aus HA
|
||
let smokeEntities = [];
|
||
let sirenEntities = [];
|
||
let mobileAppEntities = [];
|
||
let entityStates = {}; // Aktueller Zustand aller Entities
|
||
|
||
let groupCounter = 0;
|
||
let detectorCounter = 0;
|
||
let deviceCounter = 0;
|
||
|
||
// Init
|
||
async function init() {
|
||
showStatus('info', '⏳ Lade Konfiguration...');
|
||
|
||
const [configLoaded, _] = await Promise.all([
|
||
loadConfig(),
|
||
loadEntities()
|
||
]);
|
||
|
||
renderGroups();
|
||
|
||
if (configLoaded) {
|
||
const groupCount = config.groups?.length || 0;
|
||
const totalDetectors = config.groups?.reduce((sum, g) => sum + (g.detectors?.length || 0), 0) || 0;
|
||
const totalDevices = config.groups?.reduce((sum, g) => sum + (g.devices?.length || 0), 0) || 0;
|
||
showStatus('success', `✅ Config geladen: ${groupCount} Gruppen, ${totalDetectors} Rauchmelder, ${totalDevices} Geräte`);
|
||
setTimeout(hideStatus, 3000);
|
||
} else {
|
||
// Keine Config - erstelle Standard-Gruppe
|
||
addGroup();
|
||
showStatus('info', '💡 Noch keine Config gespeichert. Eine Standard-Gruppe wurde erstellt.');
|
||
}
|
||
}
|
||
|
||
// Config laden aus /local/smoke_config.json
|
||
async function loadConfig() {
|
||
try {
|
||
const response = await fetch('/local/smoke_config.json?_=' + Date.now(), {
|
||
method: 'GET',
|
||
headers: { 'Accept': 'application/json' }
|
||
});
|
||
|
||
if (response.ok) {
|
||
const loaded = await response.json();
|
||
console.log('Config geladen:', loaded);
|
||
return processLoadedConfig(loaded);
|
||
} else if (response.status === 404) {
|
||
console.log('Keine Config-Datei vorhanden');
|
||
return false;
|
||
} else {
|
||
console.log('Fehler beim Laden:', response.status);
|
||
return false;
|
||
}
|
||
} catch (error) {
|
||
console.log('Config konnte nicht geladen werden:', error.message);
|
||
return false;
|
||
}
|
||
}
|
||
|
||
// Geladene Config verarbeiten - Migration von alter zu neuer Struktur
|
||
function processLoadedConfig(loaded) {
|
||
// Prüfe ob neue Gruppen-Struktur
|
||
if (loaded.groups && Array.isArray(loaded.groups)) {
|
||
// Neue Struktur
|
||
config = loaded;
|
||
|
||
// IDs vergeben falls nicht vorhanden
|
||
config.groups.forEach((group, gi) => {
|
||
if (!group.id) group.id = ++groupCounter;
|
||
else if (group.id > groupCounter) groupCounter = group.id;
|
||
|
||
(group.detectors || []).forEach(d => {
|
||
if (!d.id) d.id = ++detectorCounter;
|
||
else if (d.id > detectorCounter) detectorCounter = d.id;
|
||
});
|
||
|
||
(group.devices || []).forEach(d => {
|
||
if (!d.id) d.id = ++deviceCounter;
|
||
else if (d.id > deviceCounter) deviceCounter = d.id;
|
||
});
|
||
});
|
||
} else {
|
||
// Alte Struktur - migrieren zu einer Gruppe
|
||
console.log('Migriere alte Config zu Gruppen-Struktur...');
|
||
|
||
const migratedGroup = {
|
||
id: ++groupCounter,
|
||
name: 'Zuhause',
|
||
notification_text: loaded.notification_text || '🔥 RAUCHMELDER ALARM! {detector} hat Rauch erkannt!',
|
||
tts_interval: loaded.tts_interval || 10,
|
||
tts_language: loaded.tts_language || 'de',
|
||
detectors: [],
|
||
devices: []
|
||
};
|
||
|
||
// Detektoren migrieren
|
||
if (loaded.detectors && Array.isArray(loaded.detectors)) {
|
||
loaded.detectors.forEach(d => {
|
||
migratedGroup.detectors.push({
|
||
id: ++detectorCounter,
|
||
sensor: d.sensor,
|
||
siren: d.siren || '',
|
||
name: d.name || ''
|
||
});
|
||
});
|
||
}
|
||
|
||
// Geräte migrieren
|
||
if (loaded.devices && Array.isArray(loaded.devices)) {
|
||
loaded.devices.forEach(d => {
|
||
migratedGroup.devices.push({
|
||
id: ++deviceCounter,
|
||
device_name: d.device_name,
|
||
device_id: d.device_id,
|
||
is_ios: d.is_ios || false
|
||
});
|
||
});
|
||
}
|
||
|
||
config = { groups: [migratedGroup] };
|
||
}
|
||
|
||
console.log('Config nach Verarbeitung:', config);
|
||
return true;
|
||
}
|
||
|
||
// Alle Entities laden
|
||
async function loadEntities() {
|
||
try {
|
||
const token = getToken();
|
||
if (!token) return;
|
||
|
||
const [statesResponse, servicesResponse] = await Promise.all([
|
||
fetch('/api/states', {
|
||
headers: {
|
||
'Authorization': `Bearer ${token}`,
|
||
'Content-Type': 'application/json'
|
||
}
|
||
}),
|
||
fetch('/api/services', {
|
||
headers: {
|
||
'Authorization': `Bearer ${token}`,
|
||
'Content-Type': 'application/json'
|
||
}
|
||
})
|
||
]);
|
||
|
||
if (statesResponse.ok) {
|
||
const states = await statesResponse.json();
|
||
|
||
// Alle States speichern für Status-Anzeige
|
||
entityStates = {};
|
||
states.forEach(s => {
|
||
entityStates[s.entity_id] = s.state;
|
||
});
|
||
|
||
// Rauchmelder
|
||
smokeEntities = states
|
||
.filter(s => s.entity_id.startsWith('binary_sensor.') &&
|
||
s.attributes.device_class === 'smoke')
|
||
.map(s => ({
|
||
entity_id: s.entity_id,
|
||
friendly_name: s.attributes.friendly_name || s.entity_id
|
||
}))
|
||
.sort((a, b) => a.friendly_name.localeCompare(b.friendly_name));
|
||
|
||
// Sirenen
|
||
const sirenKeywords = ['siren', 'sirene', 'alarm', 'horn', 'buzzer', 'warnung', 'warning', 'alert'];
|
||
sirenEntities = states
|
||
.filter(s => {
|
||
if (s.entity_id.startsWith('siren.')) return true;
|
||
if (s.entity_id.startsWith('switch.')) {
|
||
const id = s.entity_id.toLowerCase();
|
||
const name = (s.attributes.friendly_name || '').toLowerCase();
|
||
return sirenKeywords.some(kw => id.includes(kw) || name.includes(kw));
|
||
}
|
||
return false;
|
||
})
|
||
.map(s => ({
|
||
entity_id: s.entity_id,
|
||
friendly_name: s.attributes.friendly_name || s.entity_id
|
||
}))
|
||
.sort((a, b) => a.friendly_name.localeCompare(b.friendly_name));
|
||
}
|
||
|
||
// Mobile App Services
|
||
if (servicesResponse.ok) {
|
||
const services = await servicesResponse.json();
|
||
const notifyDomain = services.find(s => s.domain === 'notify');
|
||
|
||
if (notifyDomain && notifyDomain.services) {
|
||
mobileAppEntities = Object.keys(notifyDomain.services)
|
||
.filter(serviceName => serviceName.startsWith('mobile_app_'))
|
||
.map(serviceName => {
|
||
const deviceId = serviceName.replace('mobile_app_', '');
|
||
const friendlyName = deviceId
|
||
.split('_')
|
||
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
|
||
.join(' ');
|
||
|
||
return {
|
||
service_name: serviceName,
|
||
device_id: deviceId,
|
||
friendly_name: friendlyName
|
||
};
|
||
})
|
||
.sort((a, b) => a.friendly_name.localeCompare(b.friendly_name));
|
||
}
|
||
}
|
||
} catch (error) {
|
||
console.error('Fehler beim Laden der Entities:', error);
|
||
}
|
||
}
|
||
|
||
// Entities neu laden
|
||
async function refreshEntities() {
|
||
showStatus('info', '⏳ Lade Entities und Services aus Home Assistant...');
|
||
await loadEntities();
|
||
renderGroups();
|
||
showStatus('success', `✅ ${smokeEntities.length} Rauchmelder, ${sirenEntities.length} Sirenen, ${mobileAppEntities.length} Mobile App Services gefunden`);
|
||
setTimeout(hideStatus, 3000);
|
||
}
|
||
|
||
// Token
|
||
function getToken() {
|
||
const params = new URLSearchParams(window.location.search);
|
||
return params.get('token') || localStorage.getItem('ha_token') || '';
|
||
}
|
||
|
||
// ==================== GRUPPEN ====================
|
||
|
||
function addGroup() {
|
||
config.groups.push({
|
||
id: ++groupCounter,
|
||
name: 'Neue Gruppe',
|
||
notification_text: '🔥 RAUCHMELDER ALARM! {detector} hat Rauch erkannt!',
|
||
tts_interval: 10,
|
||
tts_language: 'de',
|
||
detectors: [],
|
||
devices: []
|
||
});
|
||
renderGroups();
|
||
|
||
// Zur neuen Gruppe scrollen
|
||
setTimeout(() => {
|
||
const groups = document.querySelectorAll('.group-container');
|
||
if (groups.length > 0) {
|
||
groups[groups.length - 1].scrollIntoView({ behavior: 'smooth' });
|
||
}
|
||
}, 100);
|
||
}
|
||
|
||
function deleteGroup(groupId) {
|
||
const group = config.groups.find(g => g.id === groupId);
|
||
if (!group) return;
|
||
|
||
if (!confirm(`Gruppe "${group.name}" wirklich löschen?\n\nAlle Rauchmelder und Geräte in dieser Gruppe werden entfernt.`)) {
|
||
return;
|
||
}
|
||
|
||
config.groups = config.groups.filter(g => g.id !== groupId);
|
||
renderGroups();
|
||
}
|
||
|
||
function toggleGroup(groupId) {
|
||
const container = document.querySelector(`[data-group-id="${groupId}"]`);
|
||
if (container) {
|
||
container.classList.toggle('collapsed');
|
||
}
|
||
}
|
||
|
||
function startRenameGroup(groupId) {
|
||
const group = config.groups.find(g => g.id === groupId);
|
||
if (!group) return;
|
||
|
||
const displayEl = document.getElementById(`group-name-display-${groupId}`);
|
||
const inputEl = document.getElementById(`group-name-input-${groupId}`);
|
||
|
||
if (displayEl && inputEl) {
|
||
displayEl.style.display = 'none';
|
||
inputEl.style.display = 'block';
|
||
inputEl.value = group.name;
|
||
inputEl.focus();
|
||
inputEl.select();
|
||
}
|
||
}
|
||
|
||
function finishRenameGroup(groupId) {
|
||
const group = config.groups.find(g => g.id === groupId);
|
||
if (!group) return;
|
||
|
||
const inputEl = document.getElementById(`group-name-input-${groupId}`);
|
||
if (inputEl && inputEl.value.trim()) {
|
||
group.name = inputEl.value.trim();
|
||
}
|
||
|
||
renderGroups();
|
||
}
|
||
|
||
function updateGroupField(groupId, field, value) {
|
||
const group = config.groups.find(g => g.id === groupId);
|
||
if (group) {
|
||
group[field] = value;
|
||
}
|
||
}
|
||
|
||
// ==================== DETEKTOREN ====================
|
||
|
||
function addDetector(groupId) {
|
||
const group = config.groups.find(g => g.id === groupId);
|
||
if (!group) return;
|
||
|
||
group.detectors.push({
|
||
id: ++detectorCounter,
|
||
sensor: '',
|
||
siren: '',
|
||
name: ''
|
||
});
|
||
renderGroups();
|
||
}
|
||
|
||
function deleteDetector(groupId, detectorId) {
|
||
const group = config.groups.find(g => g.id === groupId);
|
||
if (!group) return;
|
||
|
||
group.detectors = group.detectors.filter(d => d.id !== detectorId);
|
||
renderGroups();
|
||
}
|
||
|
||
function updateDetectorField(groupId, detectorId, field, value) {
|
||
const group = config.groups.find(g => g.id === groupId);
|
||
if (!group) return;
|
||
|
||
const detector = group.detectors.find(d => d.id === detectorId);
|
||
if (detector) {
|
||
detector[field] = value;
|
||
if (field === 'sensor' || field === 'name') {
|
||
renderGroups();
|
||
}
|
||
}
|
||
}
|
||
|
||
function handleSirenChange(groupId, detectorId, value) {
|
||
if (value === '__custom__') {
|
||
const entityId = prompt('Entity-ID der Sirene eingeben:\n(z.B. switch.meine_sirene oder siren.alarm)');
|
||
if (entityId && entityId.trim()) {
|
||
updateDetectorField(groupId, detectorId, 'siren', entityId.trim());
|
||
} else {
|
||
updateDetectorField(groupId, detectorId, 'siren', '');
|
||
}
|
||
} else {
|
||
updateDetectorField(groupId, detectorId, 'siren', value);
|
||
}
|
||
renderGroups();
|
||
}
|
||
|
||
// Test-Alarm für einen Rauchmelder auslösen
|
||
async function testDetector(entityId, groupId) {
|
||
if (!entityId) {
|
||
showStatus('error', '⚠️ Kein Sensor ausgewählt!');
|
||
return;
|
||
}
|
||
|
||
const token = getToken();
|
||
if (!token) {
|
||
showStatus('error', '⚠️ Kein Token!');
|
||
return;
|
||
}
|
||
|
||
const group = config.groups.find(g => g.id === groupId);
|
||
const detector = group?.detectors.find(d => d.sensor === entityId);
|
||
const detectorName = detector?.name ||
|
||
smokeEntities.find(e => e.entity_id === entityId)?.friendly_name ||
|
||
entityId;
|
||
|
||
if (!confirm(`Test-Alarm für "${detectorName}" auslösen?\n\nDies triggert den Alarm-Flow für Gruppe "${group?.name}":\n- Benachrichtigungen\n- TTS-Durchsagen\n- Sirene (falls konfiguriert)\n\nNach 5 Sekunden wird der Sensor wieder auf "off" gesetzt.`)) {
|
||
return;
|
||
}
|
||
|
||
try {
|
||
showStatus('info', `⏳ Setze ${detectorName} auf "on"...`);
|
||
|
||
const stateResponse = await fetch(`/api/states/${entityId}`, {
|
||
headers: {
|
||
'Authorization': `Bearer ${token}`,
|
||
'Content-Type': 'application/json'
|
||
}
|
||
});
|
||
|
||
let attributes = {
|
||
device_class: 'smoke',
|
||
friendly_name: detectorName
|
||
};
|
||
|
||
if (stateResponse.ok) {
|
||
const currentState = await stateResponse.json();
|
||
attributes = currentState.attributes || attributes;
|
||
}
|
||
|
||
const response = await fetch(`/api/states/${entityId}`, {
|
||
method: 'POST',
|
||
headers: {
|
||
'Authorization': `Bearer ${token}`,
|
||
'Content-Type': 'application/json'
|
||
},
|
||
body: JSON.stringify({
|
||
state: 'on',
|
||
attributes: attributes
|
||
})
|
||
});
|
||
|
||
if (response.ok) {
|
||
showStatus('success', `🔥 Test-Alarm für "${detectorName}" ausgelöst! Wird in 5 Sek. zurückgesetzt...`);
|
||
|
||
setTimeout(async () => {
|
||
try {
|
||
await fetch(`/api/states/${entityId}`, {
|
||
method: 'POST',
|
||
headers: {
|
||
'Authorization': `Bearer ${token}`,
|
||
'Content-Type': 'application/json'
|
||
},
|
||
body: JSON.stringify({
|
||
state: 'off',
|
||
attributes: attributes
|
||
})
|
||
});
|
||
showStatus('success', `✅ "${detectorName}" wieder auf "off" gesetzt.`);
|
||
setTimeout(hideStatus, 3000);
|
||
} catch (e) {
|
||
console.error('Fehler beim Zurücksetzen:', e);
|
||
}
|
||
}, 5000);
|
||
} else {
|
||
showStatus('error', `❌ Fehler: ${response.status}`);
|
||
}
|
||
} catch (error) {
|
||
showStatus('error', '❌ ' + error.message);
|
||
}
|
||
}
|
||
|
||
// ==================== GERÄTE ====================
|
||
|
||
function addDevice(groupId) {
|
||
const group = config.groups.find(g => g.id === groupId);
|
||
if (!group) return;
|
||
|
||
group.devices.push({
|
||
id: ++deviceCounter,
|
||
device_name: '',
|
||
device_id: '',
|
||
is_ios: false
|
||
});
|
||
renderGroups();
|
||
}
|
||
|
||
function deleteDevice(groupId, deviceId) {
|
||
const group = config.groups.find(g => g.id === groupId);
|
||
if (!group) return;
|
||
|
||
group.devices = group.devices.filter(d => d.id !== deviceId);
|
||
renderGroups();
|
||
}
|
||
|
||
function setPlatform(groupId, deviceId, isIos) {
|
||
const group = config.groups.find(g => g.id === groupId);
|
||
if (!group) return;
|
||
|
||
const device = group.devices.find(d => d.id === deviceId);
|
||
if (device) {
|
||
device.is_ios = isIos;
|
||
renderGroups();
|
||
}
|
||
}
|
||
|
||
function updateDeviceField(groupId, deviceId, field, value) {
|
||
const group = config.groups.find(g => g.id === groupId);
|
||
if (!group) return;
|
||
|
||
const device = group.devices.find(d => d.id === deviceId);
|
||
if (device) device[field] = value;
|
||
}
|
||
|
||
function handleDeviceIdChange(groupId, deviceId, value) {
|
||
if (value === '__custom__') {
|
||
const customId = prompt('Device ID manuell eingeben:\n(z.B. stefan_iphone oder mein_handy)');
|
||
if (customId && customId.trim()) {
|
||
updateDeviceField(groupId, deviceId, 'device_id', customId.trim());
|
||
} else {
|
||
updateDeviceField(groupId, deviceId, 'device_id', '');
|
||
}
|
||
} else {
|
||
updateDeviceField(groupId, deviceId, 'device_id', value);
|
||
}
|
||
renderGroups();
|
||
}
|
||
|
||
// ==================== NOTFALL-AKTIONEN PRO GRUPPE ====================
|
||
|
||
// STOPP für ein einzelnes Gerät in einer Gruppe
|
||
async function stopDeviceNotifications(groupId, deviceId) {
|
||
const token = getToken();
|
||
if (!token) {
|
||
showStatus('error', '⚠️ Kein Token!');
|
||
return;
|
||
}
|
||
|
||
const group = config.groups.find(g => g.id === groupId);
|
||
if (!group) return;
|
||
|
||
const device = group.devices.find(d => d.id === deviceId);
|
||
if (!device) return;
|
||
|
||
showStatus('info', `⏳ Stoppe Benachrichtigungen für "${device.device_name}"...`);
|
||
|
||
try {
|
||
// Event an Node-RED senden um TTS-Loop für dieses Gerät zu stoppen
|
||
// Wir simulieren den STOP_SMOKE Event wie vom Button auf dem Handy
|
||
await fetch('/api/events/mobile_app_notification_action', {
|
||
method: 'POST',
|
||
headers: {
|
||
'Authorization': `Bearer ${token}`,
|
||
'Content-Type': 'application/json'
|
||
},
|
||
body: JSON.stringify({
|
||
action: `STOP_SMOKE_${groupId}_${device.device_id}`
|
||
})
|
||
});
|
||
|
||
// Zusätzlich Notifications auf diesem Gerät löschen
|
||
const serviceName = 'mobile_app_' + device.device_id;
|
||
|
||
for (let detector of group.detectors) {
|
||
const tag = 'smoke_alarm_' + groupId + '_' + detector.sensor.replace(/\./g, '_');
|
||
try {
|
||
await fetch('/api/services/notify/' + serviceName, {
|
||
method: 'POST',
|
||
headers: {
|
||
'Authorization': `Bearer ${token}`,
|
||
'Content-Type': 'application/json'
|
||
},
|
||
body: JSON.stringify({
|
||
message: 'clear_notification',
|
||
data: { tag: tag }
|
||
})
|
||
});
|
||
} catch (e) {
|
||
console.error('Fehler beim Löschen der Notification:', e);
|
||
}
|
||
}
|
||
|
||
// Auch den Critical Alert Tag löschen (für iOS)
|
||
try {
|
||
await fetch('/api/services/notify/' + serviceName, {
|
||
method: 'POST',
|
||
headers: {
|
||
'Authorization': `Bearer ${token}`,
|
||
'Content-Type': 'application/json'
|
||
},
|
||
body: JSON.stringify({
|
||
message: 'clear_notification',
|
||
data: { tag: 'smoke_critical' }
|
||
})
|
||
});
|
||
} catch (e) {
|
||
console.error('Fehler beim Löschen des Critical Alerts:', e);
|
||
}
|
||
|
||
showStatus('success', `✅ Benachrichtigungen für "${device.device_name}" gestoppt!`);
|
||
} catch (error) {
|
||
showStatus('error', '❌ ' + error.message);
|
||
}
|
||
|
||
setTimeout(hideStatus, 5000);
|
||
}
|
||
|
||
async function stopGroupNotifications(groupId) {
|
||
const token = getToken();
|
||
if (!token) {
|
||
showStatus('error', '⚠️ Kein Token!');
|
||
return;
|
||
}
|
||
|
||
const group = config.groups.find(g => g.id === groupId);
|
||
if (!group || group.devices.length === 0) {
|
||
showStatus('error', '⚠️ Keine Geräte in dieser Gruppe!');
|
||
return;
|
||
}
|
||
|
||
showStatus('info', `⏳ Stoppe Benachrichtigungen für "${group.name}"...`);
|
||
|
||
try {
|
||
// Event an Node-RED senden um TTS-Loops dieser Gruppe zu stoppen
|
||
await fetch('/api/events/smoke_stop_group', {
|
||
method: 'POST',
|
||
headers: {
|
||
'Authorization': `Bearer ${token}`,
|
||
'Content-Type': 'application/json'
|
||
},
|
||
body: JSON.stringify({
|
||
action: 'stop_group_notifications',
|
||
group_id: groupId,
|
||
group_name: group.name
|
||
})
|
||
});
|
||
|
||
// Notifications auf Geräten dieser Gruppe löschen
|
||
let successCount = 0;
|
||
for (let device of group.devices) {
|
||
const serviceName = 'mobile_app_' + device.device_id;
|
||
|
||
try {
|
||
for (let detector of group.detectors) {
|
||
const tag = 'smoke_alarm_' + groupId + '_' + detector.sensor.replace(/\./g, '_');
|
||
await fetch('/api/services/notify/' + serviceName, {
|
||
method: 'POST',
|
||
headers: {
|
||
'Authorization': `Bearer ${token}`,
|
||
'Content-Type': 'application/json'
|
||
},
|
||
body: JSON.stringify({
|
||
message: 'clear_notification',
|
||
data: { tag: tag }
|
||
})
|
||
});
|
||
}
|
||
successCount++;
|
||
} catch (e) {
|
||
console.error('Fehler bei', device.device_name, e);
|
||
}
|
||
}
|
||
|
||
showStatus('success', `✅ Benachrichtigungen für "${group.name}" gestoppt (${successCount} Geräte)!`);
|
||
} catch (error) {
|
||
showStatus('error', '❌ ' + error.message);
|
||
}
|
||
|
||
setTimeout(hideStatus, 5000);
|
||
}
|
||
|
||
async function stopGroupSirens(groupId) {
|
||
const token = getToken();
|
||
if (!token) {
|
||
showStatus('error', '⚠️ Kein Token!');
|
||
return;
|
||
}
|
||
|
||
const group = config.groups.find(g => g.id === groupId);
|
||
if (!group) return;
|
||
|
||
const sirens = [...new Set(
|
||
group.detectors
|
||
.filter(d => d.siren)
|
||
.map(d => d.siren)
|
||
)];
|
||
|
||
if (sirens.length === 0) {
|
||
showStatus('error', `⚠️ Keine Sirenen in Gruppe "${group.name}" konfiguriert!`);
|
||
return;
|
||
}
|
||
|
||
showStatus('info', `⏳ Schalte Sirenen für "${group.name}" aus...`);
|
||
|
||
let successCount = 0;
|
||
for (let siren of sirens) {
|
||
try {
|
||
const domain = siren.split('.')[0];
|
||
await fetch(`/api/services/${domain}/turn_off`, {
|
||
method: 'POST',
|
||
headers: {
|
||
'Authorization': `Bearer ${token}`,
|
||
'Content-Type': 'application/json'
|
||
},
|
||
body: JSON.stringify({
|
||
entity_id: siren
|
||
})
|
||
});
|
||
successCount++;
|
||
} catch (error) {
|
||
console.error('Fehler bei Sirene', siren, error);
|
||
}
|
||
}
|
||
|
||
showStatus('success', `✅ ${successCount} Sirenen in "${group.name}" ausgeschaltet!`);
|
||
setTimeout(hideStatus, 5000);
|
||
}
|
||
|
||
// ==================== RENDER ====================
|
||
|
||
function escapeHtml(text) {
|
||
if (!text) return '';
|
||
return text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||
}
|
||
|
||
function renderGroups() {
|
||
const container = document.getElementById('groupsContainer');
|
||
|
||
if (config.groups.length === 0) {
|
||
container.innerHTML = '<div class="empty-state">Noch keine Gruppen konfiguriert.<br>Klicke auf "Neue Gruppe hinzufügen".</div>';
|
||
return;
|
||
}
|
||
|
||
container.innerHTML = config.groups.map(group => `
|
||
<div class="group-container" data-group-id="${group.id}">
|
||
<div class="group-header" onclick="toggleGroup(${group.id})">
|
||
<div class="group-header-left">
|
||
<span class="group-icon">🏠</span>
|
||
<div>
|
||
<div class="group-name-display" id="group-name-display-${group.id}">${escapeHtml(group.name)}</div>
|
||
<input type="text" class="group-name-input" id="group-name-input-${group.id}"
|
||
style="display:none"
|
||
value="${escapeHtml(group.name)}"
|
||
onclick="event.stopPropagation()"
|
||
onblur="finishRenameGroup(${group.id})"
|
||
onkeyup="if(event.key==='Enter')finishRenameGroup(${group.id})">
|
||
<div class="group-stats">${group.detectors.length} Rauchmelder · ${group.devices.length} Geräte</div>
|
||
</div>
|
||
</div>
|
||
<div class="group-header-right" onclick="event.stopPropagation()">
|
||
<div class="group-actions">
|
||
<button class="group-action-btn" onclick="startRenameGroup(${group.id})" title="Umbenennen">✏️</button>
|
||
<button class="group-action-btn danger" onclick="deleteGroup(${group.id})" title="Löschen">🗑️</button>
|
||
</div>
|
||
<span class="group-toggle" onclick="event.stopPropagation(); toggleGroup(${group.id})">▼</span>
|
||
</div>
|
||
</div>
|
||
<div class="group-content">
|
||
${renderGroupContent(group)}
|
||
</div>
|
||
</div>
|
||
`).join('');
|
||
}
|
||
|
||
function renderGroupContent(group) {
|
||
return `
|
||
<!-- Benachrichtigungen -->
|
||
<div class="section">
|
||
<div class="section-title">📝 Benachrichtigungen</div>
|
||
|
||
<div class="form-group">
|
||
<label>Benachrichtigungstext</label>
|
||
<textarea id="notificationText-${group.id}"
|
||
onchange="updateGroupField(${group.id}, 'notification_text', this.value)"
|
||
placeholder="🔥 RAUCHMELDER ALARM! {detector} hat Rauch erkannt!">${escapeHtml(group.notification_text)}</textarea>
|
||
<div class="placeholder-info">
|
||
Platzhalter: <code>{detector}</code> wird durch den Rauchmelder-Namen ersetzt
|
||
</div>
|
||
</div>
|
||
|
||
<div class="form-group">
|
||
<label>TTS Intervall</label>
|
||
<div class="range-group">
|
||
<input type="range" id="ttsInterval-${group.id}" min="5" max="60" step="5"
|
||
value="${group.tts_interval}"
|
||
oninput="updateGroupField(${group.id}, 'tts_interval', parseInt(this.value)); document.getElementById('ttsIntervalValue-${group.id}').textContent = this.value + 's'">
|
||
<div class="range-value" id="ttsIntervalValue-${group.id}">${group.tts_interval}s</div>
|
||
</div>
|
||
<div class="hint">Wie oft die Sprachausgabe wiederholt wird</div>
|
||
</div>
|
||
|
||
<div class="form-group">
|
||
<label>TTS Sprache</label>
|
||
<select id="ttsLanguage-${group.id}" onchange="updateGroupField(${group.id}, 'tts_language', this.value)">
|
||
<option value="de" ${group.tts_language === 'de' ? 'selected' : ''}>Deutsch</option>
|
||
<option value="en" ${group.tts_language === 'en' ? 'selected' : ''}>English</option>
|
||
<option value="fr" ${group.tts_language === 'fr' ? 'selected' : ''}>Français</option>
|
||
<option value="es" ${group.tts_language === 'es' ? 'selected' : ''}>Español</option>
|
||
<option value="it" ${group.tts_language === 'it' ? 'selected' : ''}>Italiano</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Rauchmelder -->
|
||
<div class="section">
|
||
<div class="section-title">
|
||
🔥 Rauchmelder
|
||
<button type="button" class="refresh-btn" onclick="refreshEntities()">🔄 Entities laden</button>
|
||
</div>
|
||
|
||
<div class="card-list">
|
||
${renderDetectors(group)}
|
||
</div>
|
||
|
||
<button class="add-btn" onclick="addDetector(${group.id})">
|
||
➕ Rauchmelder hinzufügen
|
||
</button>
|
||
</div>
|
||
|
||
<!-- Geräte -->
|
||
<div class="section">
|
||
<div class="section-title">📱 Benachrichtigungs-Geräte</div>
|
||
|
||
<div class="card-list">
|
||
${renderDevices(group)}
|
||
</div>
|
||
|
||
<button class="add-btn" onclick="addDevice(${group.id})">
|
||
➕ Gerät hinzufügen
|
||
</button>
|
||
</div>
|
||
|
||
<!-- Notfall-Aktionen für diese Gruppe -->
|
||
<div class="section emergency-section">
|
||
<div class="section-title">🚨 Notfall-Aktionen für "${escapeHtml(group.name)}"</div>
|
||
<div class="emergency-buttons">
|
||
<button class="emergency-btn stop-btn" onclick="stopGroupNotifications(${group.id})">
|
||
🔇 Benachrichtigungen stoppen
|
||
</button>
|
||
<button class="emergency-btn siren-btn" onclick="stopGroupSirens(${group.id})">
|
||
🔕 Sirenen ausschalten
|
||
</button>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
function renderDetectors(group) {
|
||
if (group.detectors.length === 0) {
|
||
return '<div class="empty-state">Noch keine Rauchmelder konfiguriert</div>';
|
||
}
|
||
|
||
return group.detectors.map(detector => {
|
||
const sensorName = detector.name ||
|
||
smokeEntities.find(e => e.entity_id === detector.sensor)?.friendly_name ||
|
||
detector.sensor ||
|
||
'Neuer Rauchmelder';
|
||
|
||
// Status ermitteln
|
||
const smokeState = detector.sensor ? entityStates[detector.sensor] : null;
|
||
const sirenState = detector.siren ? entityStates[detector.siren] : null;
|
||
|
||
const smokeActive = smokeState === 'on';
|
||
const sirenActive = sirenState === 'on';
|
||
|
||
return `
|
||
<div class="card">
|
||
<div class="card-header">
|
||
<div class="card-title">
|
||
🔥 <span>${escapeHtml(sensorName)}</span>
|
||
</div>
|
||
<div class="card-header-buttons">
|
||
<button class="test-btn" onclick="testDetector('${detector.sensor}', ${group.id})" ${!detector.sensor ? 'disabled' : ''} title="Test-Alarm auslösen">🧪 Test</button>
|
||
<button class="delete-btn" onclick="deleteDetector(${group.id}, ${detector.id})">🗑️</button>
|
||
</div>
|
||
</div>
|
||
${detector.sensor || detector.siren ? `
|
||
<div class="status-badges">
|
||
${detector.sensor ? `
|
||
<span class="status-badge smoke ${smokeActive ? 'active' : ''}">
|
||
${smokeActive ? '🔴 RAUCH ERKANNT' : '✅ Kein Rauch'}
|
||
${smokeState ? '' : ' (unbekannt)'}
|
||
</span>
|
||
` : ''}
|
||
${detector.siren ? `
|
||
<span class="status-badge siren ${sirenActive ? 'active' : ''}">
|
||
${sirenActive ? '🔔 Sirene AN' : '🔕 Sirene aus'}
|
||
${sirenState ? '' : ' (unbekannt)'}
|
||
</span>
|
||
` : `
|
||
<span class="status-badge no-siren">
|
||
⚠️ Keine Sirene
|
||
</span>
|
||
`}
|
||
</div>
|
||
` : ''}
|
||
<div class="card-fields">
|
||
<div class="form-group">
|
||
<label>Rauchmelder-Sensor</label>
|
||
${renderSmokeSelect(group.id, detector)}
|
||
</div>
|
||
<div class="form-group">
|
||
<label>Sirene/Alarm (optional)</label>
|
||
${renderSirenSelect(group.id, detector)}
|
||
</div>
|
||
<div class="form-group full-width">
|
||
<label>Anzeigename (optional)</label>
|
||
<input type="text" value="${escapeHtml(detector.name)}"
|
||
onchange="updateDetectorField(${group.id}, ${detector.id}, 'name', this.value)"
|
||
placeholder="z.B. Rauchmelder Flur EG">
|
||
<div class="hint">Wird in Benachrichtigungen verwendet. Wenn leer, wird der Entity-Name verwendet.</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`}).join('');
|
||
}
|
||
|
||
function renderSmokeSelect(groupId, detector) {
|
||
if (smokeEntities.length === 0) {
|
||
return `<select disabled><option>Keine Rauchmelder gefunden - Klicke "Entities laden"</option></select>`;
|
||
}
|
||
|
||
const options = smokeEntities.map(e => {
|
||
const selected = detector.sensor === e.entity_id ? 'selected' : '';
|
||
const label = `${e.friendly_name} ▸ ${e.entity_id}`;
|
||
return `<option value="${e.entity_id}" ${selected}>${label}</option>`;
|
||
}).join('');
|
||
|
||
return `<select onchange="updateDetectorField(${groupId}, ${detector.id}, 'sensor', this.value)">
|
||
<option value="">-- Bitte wählen --</option>
|
||
${options}
|
||
</select>`;
|
||
}
|
||
|
||
function renderSirenSelect(groupId, detector) {
|
||
const options = sirenEntities.map(e => {
|
||
const selected = detector.siren === e.entity_id ? 'selected' : '';
|
||
const label = `${e.friendly_name} ▸ ${e.entity_id}`;
|
||
return `<option value="${e.entity_id}" ${selected}>${label}</option>`;
|
||
}).join('');
|
||
|
||
const currentInList = !detector.siren || sirenEntities.some(e => e.entity_id === detector.siren);
|
||
const customOption = !currentInList
|
||
? `<option value="${detector.siren}" selected>📝 ${detector.siren}</option>`
|
||
: '';
|
||
|
||
return `<select onchange="handleSirenChange(${groupId}, ${detector.id}, this.value)">
|
||
<option value="">-- Keine Sirene --</option>
|
||
${options}
|
||
<option value="__custom__">📝 Manuell eingeben...</option>
|
||
${customOption}
|
||
</select>`;
|
||
}
|
||
|
||
function renderDevices(group) {
|
||
if (group.devices.length === 0) {
|
||
return '<div class="empty-state">Noch keine Geräte konfiguriert</div>';
|
||
}
|
||
|
||
return group.devices.map(device => `
|
||
<div class="card">
|
||
<div class="card-header">
|
||
<div class="card-title">
|
||
${device.is_ios ? '🍎' : '🤖'}
|
||
<span>${device.device_name || 'Neues Gerät'}</span>
|
||
</div>
|
||
<div class="card-header-buttons">
|
||
<button class="stop-device-btn" onclick="stopDeviceNotifications(${group.id}, ${device.id})" ${!device.device_id ? 'disabled' : ''} title="Benachrichtigungen für dieses Gerät stoppen">🛑 STOPP</button>
|
||
<button class="delete-btn" onclick="deleteDevice(${group.id}, ${device.id})">🗑️</button>
|
||
</div>
|
||
</div>
|
||
<div class="card-fields">
|
||
<div class="form-group">
|
||
<label>Gerätename</label>
|
||
<input type="text" value="${escapeHtml(device.device_name)}"
|
||
onchange="updateDeviceField(${group.id}, ${device.id}, 'device_name', this.value)"
|
||
placeholder="z.B. Stefan iPhone">
|
||
</div>
|
||
<div class="form-group">
|
||
<label>Device ID</label>
|
||
${renderDeviceIdSelect(group.id, device)}
|
||
</div>
|
||
<div class="form-group full-width">
|
||
<label>Platform</label>
|
||
<div class="platform-selector">
|
||
<button type="button" class="platform-btn ${!device.is_ios ? 'active' : ''}"
|
||
onclick="setPlatform(${group.id}, ${device.id}, false)">🤖 Android</button>
|
||
<button type="button" class="platform-btn ${device.is_ios ? 'active' : ''}"
|
||
onclick="setPlatform(${group.id}, ${device.id}, true)">🍎 iOS</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`).join('');
|
||
}
|
||
|
||
function renderDeviceIdSelect(groupId, device) {
|
||
const options = mobileAppEntities.map(e => {
|
||
const selected = device.device_id === e.device_id ? 'selected' : '';
|
||
const label = `${e.friendly_name} ▸ ${e.device_id}`;
|
||
return `<option value="${e.device_id}" ${selected}>${label}</option>`;
|
||
}).join('');
|
||
|
||
const currentInList = !device.device_id || mobileAppEntities.some(e => e.device_id === device.device_id);
|
||
const customOption = !currentInList
|
||
? `<option value="${device.device_id}" selected>📝 ${device.device_id}</option>`
|
||
: '';
|
||
|
||
return `<select onchange="handleDeviceIdChange(${groupId}, ${device.id}, this.value)">
|
||
<option value="">-- Bitte wählen --</option>
|
||
${options}
|
||
<option value="__custom__">📝 Manuell eingeben...</option>
|
||
${customOption}
|
||
</select>`;
|
||
}
|
||
|
||
// ==================== SPEICHERN ====================
|
||
|
||
async function saveConfig() {
|
||
const token = getToken();
|
||
if (!token) {
|
||
showStatus('error', '⚠️ Kein Token! Füge ?token=DEIN_TOKEN zur URL hinzu');
|
||
return;
|
||
}
|
||
|
||
// Validierung
|
||
for (let group of config.groups) {
|
||
if (!group.name || !group.name.trim()) {
|
||
showStatus('error', `⚠️ Eine Gruppe hat keinen Namen!`);
|
||
return;
|
||
}
|
||
|
||
for (let detector of group.detectors) {
|
||
if (!detector.sensor) {
|
||
showStatus('error', `⚠️ In Gruppe "${group.name}": Bitte wähle für jeden Rauchmelder einen Sensor aus!`);
|
||
return;
|
||
}
|
||
}
|
||
|
||
for (let device of group.devices) {
|
||
if (!device.device_name || !device.device_id) {
|
||
showStatus('error', `⚠️ In Gruppe "${group.name}": "${device.device_name || 'Gerät'}" hat leere Pflichtfelder!`);
|
||
return;
|
||
}
|
||
}
|
||
}
|
||
|
||
// Speicher-Objekt
|
||
const saveData = {
|
||
groups: config.groups.map(g => ({
|
||
id: g.id,
|
||
name: g.name,
|
||
notification_text: g.notification_text,
|
||
tts_interval: g.tts_interval,
|
||
tts_language: g.tts_language,
|
||
detectors: g.detectors.map(d => ({
|
||
sensor: d.sensor,
|
||
siren: d.siren || '',
|
||
name: d.name || ''
|
||
})),
|
||
devices: g.devices.map(d => ({
|
||
device_name: d.device_name,
|
||
device_id: d.device_id,
|
||
is_ios: d.is_ios
|
||
}))
|
||
}))
|
||
};
|
||
|
||
try {
|
||
const response = await fetch('/api/events/smoke_config_update', {
|
||
method: 'POST',
|
||
headers: {
|
||
'Authorization': `Bearer ${token}`,
|
||
'Content-Type': 'application/json'
|
||
},
|
||
body: JSON.stringify({
|
||
config: saveData
|
||
})
|
||
});
|
||
|
||
if (response.ok) {
|
||
showStatus('success', '✅ Gespeichert! Config wurde an Node-RED gesendet.');
|
||
setTimeout(hideStatus, 5000);
|
||
} else {
|
||
showStatus('error', '❌ Fehler: ' + response.status);
|
||
}
|
||
} catch (error) {
|
||
showStatus('error', '❌ ' + error.message);
|
||
}
|
||
}
|
||
|
||
// Status
|
||
function showStatus(type, message) {
|
||
const bar = document.getElementById('statusBar');
|
||
bar.className = 'status-bar ' + type;
|
||
bar.textContent = message;
|
||
}
|
||
|
||
function hideStatus() {
|
||
document.getElementById('statusBar').className = 'status-bar';
|
||
}
|
||
|
||
// iframe-Modus erkennen
|
||
function checkIframeMode() {
|
||
const params = new URLSearchParams(window.location.search);
|
||
if (window.self !== window.top || params.get('embed') === '1') {
|
||
document.body.classList.add('iframe-mode');
|
||
}
|
||
}
|
||
|
||
// Seite komplett neu laden
|
||
function refreshPage() {
|
||
window.location.reload();
|
||
}
|
||
|
||
// Status automatisch aktualisieren (alle 5 Sekunden)
|
||
async function refreshStatus() {
|
||
const token = getToken();
|
||
if (!token) return;
|
||
|
||
try {
|
||
const response = await fetch('/api/states', {
|
||
headers: {
|
||
'Authorization': `Bearer ${token}`,
|
||
'Content-Type': 'application/json'
|
||
}
|
||
});
|
||
|
||
if (response.ok) {
|
||
const states = await response.json();
|
||
entityStates = {};
|
||
states.forEach(s => {
|
||
entityStates[s.entity_id] = s.state;
|
||
});
|
||
renderGroups();
|
||
}
|
||
} catch (error) {
|
||
console.log('Status-Refresh fehlgeschlagen:', error.message);
|
||
}
|
||
}
|
||
|
||
// Start
|
||
checkIframeMode();
|
||
init();
|
||
|
||
// Status alle 5 Sekunden aktualisieren
|
||
setInterval(refreshStatus, 5000);
|
||
</script>
|
||
</body>
|
||
</html>
|