ha-smoke-detection-notify/node-red/settings-smoke-notify.html

1830 lines
64 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>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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
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>