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

1359 lines
47 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; }
.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;
}
.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;
}
.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;
}
.conditional-field {
display: none;
}
.conditional-field.visible {
display: block;
}
.ios-shortcut-info {
display: flex;
gap: 15px;
background: linear-gradient(135deg, #1a2a1a 0%, #1a1a2e 100%);
border: 1px solid #3a5a3a;
border-radius: 8px;
padding: 15px;
}
.ios-shortcut-info .shortcut-icon {
font-size: 32px;
}
.ios-shortcut-info .shortcut-text strong {
color: #38ef7d;
display: block;
margin-bottom: 8px;
}
.ios-shortcut-info .shortcut-text p {
color: #aaa;
font-size: 13px;
margin: 4px 0;
}
.ios-shortcut-info .shortcut-text code {
background: #2a2a4a;
padding: 2px 6px;
border-radius: 4px;
color: #667eea;
}
.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;
}
/* 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);
}
.test-btn {
padding: 18px 30px;
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
border: none;
border-radius: 10px;
color: white;
font-size: 16px;
font-weight: 600;
cursor: pointer;
transition: transform 0.2s;
}
.test-btn:hover {
transform: translateY(-2px);
}
.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;
}
/* Notfall-Buttons */
.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);
}
@media (max-width: 600px) {
.card-fields {
grid-template-columns: 1fr;
}
.actions {
flex-direction: column;
}
.emergency-buttons {
flex-direction: column;
}
}
</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>
<!-- Benachrichtigungen -->
<div class="section">
<div class="section-title">📝 Benachrichtigungen</div>
<div class="form-group">
<label>Benachrichtigungstext</label>
<textarea id="notificationText" placeholder="🔥 RAUCHMELDER ALARM! {detector} hat Rauch erkannt!"></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" min="5" max="60" step="5" value="10" oninput="updateRangeValue(this)">
<div class="range-value" id="ttsIntervalValue">10s</div>
</div>
<div class="hint">Wie oft die Sprachausgabe wiederholt wird</div>
</div>
<div class="form-group">
<label>TTS Sprache (für iOS)</label>
<select id="ttsLanguage">
<option value="de">Deutsch</option>
<option value="en">English</option>
<option value="fr">Français</option>
<option value="es">Español</option>
<option value="it">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" id="detectorList"></div>
<button class="add-btn" onclick="addDetector()">
Rauchmelder hinzufügen
</button>
</div>
<!-- Geräte -->
<div class="section">
<div class="section-title">📱 Benachrichtigungs-Geräte</div>
<div class="card-list" id="deviceList"></div>
<button class="add-btn" onclick="addDevice()">
Gerät hinzufügen
</button>
</div>
<!-- Notfall-Aktionen -->
<div class="section emergency-section">
<div class="section-title">🚨 Notfall-Aktionen</div>
<div class="emergency-buttons">
<button class="emergency-btn stop-btn" onclick="stopAllNotifications()">
🔇 Alle Benachrichtigungen stoppen
</button>
<button class="emergency-btn siren-btn" onclick="stopAllSirens()">
🔕 Alle Sirenen ausschalten
</button>
</div>
</div>
<!-- Actions -->
<div class="actions">
<button class="save-btn" onclick="saveConfig()">
💾 Speichern
</button>
</div>
</div>
<script>
// State
let config = {
notification_text: '🔥 RAUCHMELDER ALARM! {detector} hat Rauch erkannt!',
tts_interval: 10,
tts_language: 'de',
detectors: [], // Neues Format: Array von {sensor, siren, name}
devices: []
};
// Entity-Listen aus HA
let smokeEntities = [];
let sirenEntities = [];
let mobileAppEntities = [];
let detectorCounter = 0;
let deviceCounter = 0;
// Init
async function init() {
showStatus('info', '⏳ Lade Konfiguration...');
const [configLoaded, _] = await Promise.all([
loadConfig(),
loadEntities()
]);
applyConfigToUI();
renderDetectors();
renderDevices();
if (configLoaded) {
const detCount = config.detectors?.length || 0;
const devCount = config.devices?.length || 0;
showStatus('success', `✅ Config geladen: ${detCount} Rauchmelder, ${devCount} Geräte`);
setTimeout(hideStatus, 3000);
} else {
showStatus('info', '💡 Noch keine Config gespeichert. Konfiguriere dein System und klicke "Speichern".');
}
}
// Config laden aus /local/smoke_config.json
async function loadConfig() {
try {
// Cache-Busting um immer aktuelle Version zu laden
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
function processLoadedConfig(loaded) {
// Config übernehmen
config = { ...config, ...loaded };
// Migrate old format to new format
if (loaded.smoke_detectors && typeof loaded.smoke_detectors === 'string' && !loaded.detectors) {
// Altes Format: smoke_detectors als String
config.detectors = [];
const lines = loaded.smoke_detectors.split('\n').filter(l => l.trim());
lines.forEach(line => {
config.detectors.push({
id: ++detectorCounter,
sensor: line.trim(),
siren: '',
name: ''
});
});
}
// IDs für Detektoren vergeben (falls nicht vorhanden)
if (config.detectors && config.detectors.length > 0) {
config.detectors.forEach((d, i) => {
if (!d.id) d.id = ++detectorCounter;
else if (d.id > detectorCounter) detectorCounter = d.id;
});
}
// IDs für Geräte vergeben (falls nicht vorhanden)
if (config.devices && config.devices.length > 0) {
config.devices.forEach((d, i) => {
if (!d.id) d.id = ++deviceCounter;
else if (d.id > deviceCounter) deviceCounter = d.id;
});
}
console.log('Config nach Verarbeitung:', config);
return true;
}
// Alle Entities laden
async function loadEntities() {
try {
const token = getToken();
if (!token) return;
// States und Services parallel laden
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();
// Rauchmelder (binary_sensor mit device_class smoke)
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/Alarme - nur echte Sirenen, keine Lampen/Steckdosen
const sirenKeywords = ['siren', 'sirene', 'alarm', 'horn', 'buzzer', 'warnung', 'warning', 'alert'];
sirenEntities = states
.filter(s => {
// siren.* Domain immer erlauben
if (s.entity_id.startsWith('siren.')) return true;
// switch.* nur wenn es wie eine Sirene aussieht
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 aus /api/services laden
if (servicesResponse.ok) {
const services = await servicesResponse.json();
// Finde die "notify" Domain
const notifyDomain = services.find(s => s.domain === 'notify');
if (notifyDomain && notifyDomain.services) {
// Filtere mobile_app_* Services
mobileAppEntities = Object.keys(notifyDomain.services)
.filter(serviceName => serviceName.startsWith('mobile_app_'))
.map(serviceName => {
// Service-Name zu lesbarem Namen konvertieren
// mobile_app_one_plus_7_pro_stefan -> One Plus 7 Pro Stefan
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));
console.log('Mobile App Services gefunden:', mobileAppEntities);
}
}
} 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();
renderDetectors();
renderDevices();
showStatus('success', `${smokeEntities.length} Rauchmelder, ${sirenEntities.length} Sirenen, ${mobileAppEntities.length} Mobile App Services gefunden`);
setTimeout(hideStatus, 3000);
}
// Config auf UI anwenden
function applyConfigToUI() {
document.getElementById('notificationText').value = config.notification_text;
document.getElementById('ttsInterval').value = config.tts_interval;
document.getElementById('ttsIntervalValue').textContent = config.tts_interval + 's';
document.getElementById('ttsLanguage').value = config.tts_language;
}
// UI-Werte sammeln
function collectConfigFromUI() {
config.notification_text = document.getElementById('notificationText').value;
config.tts_interval = parseInt(document.getElementById('ttsInterval').value);
config.tts_language = document.getElementById('ttsLanguage').value;
}
// Token
function getToken() {
const params = new URLSearchParams(window.location.search);
return params.get('token') || localStorage.getItem('ha_token') || '';
}
// Range-Wert aktualisieren
function updateRangeValue(input) {
document.getElementById('ttsIntervalValue').textContent = input.value + 's';
}
// ==================== RAUCHMELDER ====================
function addDetector() {
config.detectors.push({
id: ++detectorCounter,
sensor: '',
siren: '',
name: ''
});
renderDetectors();
setTimeout(() => {
const cards = document.querySelectorAll('#detectorList .card');
if (cards.length > 0) {
cards[cards.length - 1].scrollIntoView({ behavior: 'smooth' });
}
}, 100);
}
function deleteDetector(id) {
config.detectors = config.detectors.filter(d => d.id !== id);
renderDetectors();
}
function updateDetectorField(id, field, value) {
const detector = config.detectors.find(d => d.id === id);
if (detector) detector[field] = value;
// Re-render bei Sensor-Änderung für Titel-Update
if (field === 'sensor' || field === 'name') {
renderDetectors();
}
}
function renderDetectors() {
const container = document.getElementById('detectorList');
if (config.detectors.length === 0) {
container.innerHTML = '<div class="empty-state">Noch keine Rauchmelder konfiguriert.<br>Klicke auf "Rauchmelder hinzufügen" und wähle einen Sensor aus.</div>';
return;
}
container.innerHTML = config.detectors.map(detector => {
const sensorName = detector.name ||
smokeEntities.find(e => e.entity_id === detector.sensor)?.friendly_name ||
detector.sensor ||
'Neuer Rauchmelder';
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}')" ${!detector.sensor ? 'disabled' : ''} title="Test-Alarm auslösen">🧪 Test</button>
<button class="delete-btn" onclick="deleteDetector(${detector.id})">🗑️</button>
</div>
</div>
<div class="card-fields">
<div class="form-group">
<label>Rauchmelder-Sensor</label>
${renderSmokeSelect(detector)}
</div>
<div class="form-group">
<label>Sirene/Alarm (optional)</label>
${renderSirenSelect(detector)}
</div>
<div class="form-group full-width">
<label>Anzeigename (optional)</label>
<input type="text" value="${escapeHtml(detector.name)}"
onchange="updateDetectorField(${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(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(${detector.id}, 'sensor', this.value)">
<option value="">-- Bitte wählen --</option>
${options}
</select>`;
}
function renderSirenSelect(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('');
// Prüfe ob aktuelle Sirene in der Liste ist (falls manuell eingegeben)
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(${detector.id}, this.value)">
<option value="">-- Keine Sirene --</option>
${options}
<option value="__custom__">📝 Manuell eingeben...</option>
${customOption}
</select>`;
}
function handleSirenChange(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(detectorId, 'siren', entityId.trim());
} else {
updateDetectorField(detectorId, 'siren', '');
}
renderDetectors();
} else {
updateDetectorField(detectorId, 'siren', value);
}
}
// Test-Alarm für einen Rauchmelder auslösen
async function testDetector(entityId) {
if (!entityId) {
showStatus('error', '⚠️ Kein Sensor ausgewählt!');
return;
}
const token = getToken();
if (!token) {
showStatus('error', '⚠️ Kein Token!');
return;
}
// Finde den Detektor für den Namen
const detector = config.detectors.find(d => d.sensor === entityId);
const detectorName = detector?.name ||
smokeEntities.find(e => e.entity_id === entityId)?.friendly_name ||
entityId;
// Bestätigungsdialog
if (!confirm(`Test-Alarm für "${detectorName}" auslösen?\n\nDies triggert den kompletten Alarm-Flow:\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"...`);
// Hole zuerst den aktuellen State für die Attribute
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;
}
// Setze State auf "on"
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...`);
// Nach 5 Sekunden wieder auf "off" setzen
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 {
const errorText = await response.text();
console.error('Test-Fehler:', response.status, errorText);
showStatus('error', `❌ Fehler: ${response.status}`);
}
} catch (error) {
console.error('Test-Exception:', error);
showStatus('error', '❌ ' + error.message);
}
}
// ==================== GERÄTE ====================
function addDevice() {
config.devices.push({
id: ++deviceCounter,
device_name: '',
device_id: '',
is_ios: false,
browser_mod_entity: ''
});
renderDevices();
setTimeout(() => {
const cards = document.querySelectorAll('#deviceList .card');
if (cards.length > 0) {
cards[cards.length - 1].scrollIntoView({ behavior: 'smooth' });
}
}, 100);
}
function deleteDevice(id) {
config.devices = config.devices.filter(d => d.id !== id);
renderDevices();
}
function setPlatform(id, isIos) {
const device = config.devices.find(d => d.id === id);
if (device) {
device.is_ios = isIos;
renderDevices();
}
}
function updateDeviceField(id, field, value) {
const device = config.devices.find(d => d.id === id);
if (device) device[field] = value;
}
function renderDevices() {
const container = document.getElementById('deviceList');
if (config.devices.length === 0) {
container.innerHTML = '<div class="empty-state">Noch keine Geräte konfiguriert</div>';
return;
}
container.innerHTML = config.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>
<button class="delete-btn" onclick="deleteDevice(${device.id})">🗑️</button>
</div>
<div class="card-fields">
<div class="form-group">
<label>Gerätename</label>
<input type="text" value="${escapeHtml(device.device_name)}"
onchange="updateDeviceField(${device.id}, 'device_name', this.value)"
placeholder="z.B. Stefan iPhone">
</div>
<div class="form-group">
<label>Device ID</label>
${renderDeviceIdSelect(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(${device.id}, false)">🤖 Android</button>
<button type="button" class="platform-btn ${device.is_ios ? 'active' : ''}"
onclick="setPlatform(${device.id}, true)">🍎 iOS</button>
</div>
</div>
</div>
</div>
`).join('');
}
function renderDeviceIdSelect(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('');
// Prüfe ob aktuelle Device ID in der Liste ist (falls manuell eingegeben)
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(${device.id}, this.value)">
<option value="">-- Bitte wählen --</option>
${options}
<option value="__custom__">📝 Manuell eingeben...</option>
${customOption}
</select>`;
}
function handleDeviceIdChange(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(deviceId, 'device_id', customId.trim());
} else {
updateDeviceField(deviceId, 'device_id', '');
}
renderDevices();
} else {
updateDeviceField(deviceId, 'device_id', value);
}
}
// ==================== SPEICHERN & TESTEN ====================
function escapeHtml(text) {
if (!text) return '';
return text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
async function saveConfig() {
const token = getToken();
if (!token) {
showStatus('error', '⚠️ Kein Token! Füge ?token=DEIN_TOKEN zur URL hinzu');
return;
}
collectConfigFromUI();
// Validierung Rauchmelder
for (let detector of config.detectors) {
if (!detector.sensor) {
showStatus('error', '⚠️ Bitte wähle für jeden Rauchmelder einen Sensor aus!');
return;
}
}
// Validierung Geräte
for (let device of config.devices) {
if (!device.device_name || !device.device_id) {
showStatus('error', `⚠️ "${device.device_name || 'Gerät'}" hat leere Pflichtfelder!`);
return;
}
}
// Speicher-Objekt
const saveData = {
notification_text: config.notification_text,
tts_interval: config.tts_interval,
tts_language: config.tts_language,
detectors: config.detectors.map(d => ({
sensor: d.sensor,
siren: d.siren || '',
name: d.name || ''
})),
devices: config.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';
}
// ==================== NOTFALL-AKTIONEN ====================
async function stopAllNotifications() {
const token = getToken();
if (!token) {
showStatus('error', '⚠️ Kein Token!');
return;
}
if (config.devices.length === 0) {
showStatus('error', '⚠️ Keine Geräte konfiguriert!');
return;
}
showStatus('info', '⏳ Stoppe alle Benachrichtigungen...');
// Event an Node-RED senden um alle TTS-Loops zu stoppen
try {
await fetch('/api/events/smoke_stop_all', {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({ action: 'stop_all_notifications' })
});
// Auch Notifications auf allen Geräten löschen
let successCount = 0;
for (let device of config.devices) {
const serviceName = 'mobile_app_' + device.device_name
.toLowerCase()
.replace(/[\s-]+/g, '_')
.replace(/[^a-z0-9_]/g, '');
try {
// Lösche alle smoke_alarm Notifications
for (let detector of config.detectors) {
const tag = 'smoke_alarm_' + 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 auf ${successCount} Geräten gestoppt!`);
} catch (error) {
showStatus('error', '❌ ' + error.message);
}
setTimeout(hideStatus, 5000);
}
async function stopAllSirens() {
const token = getToken();
if (!token) {
showStatus('error', '⚠️ Kein Token!');
return;
}
// Sammle alle Sirenen aus den Detektoren
const sirens = [...new Set(
config.detectors
.filter(d => d.siren)
.map(d => d.siren)
)];
if (sirens.length === 0) {
showStatus('error', '⚠️ Keine Sirenen konfiguriert!');
return;
}
showStatus('info', '⏳ Schalte alle Sirenen aus...');
let successCount = 0;
for (let siren of sirens) {
try {
// Bestimme Domain (switch oder siren)
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 ausgeschaltet!`);
setTimeout(hideStatus, 5000);
}
// 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');
}
}
// Start
checkIframeMode();
init();
</script>
</body>
</html>