1359 lines
47 KiB
HTML
1359 lines
47 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; }
|
||
|
||
.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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||
}
|
||
|
||
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>
|