From 4c306181e43ead1b400217012b7b255d6a1005c0 Mon Sep 17 00:00:00 2001 From: duffyduck Date: Fri, 26 Dec 2025 13:09:35 +0100 Subject: [PATCH] =?UTF-8?q?button=20stop=20f=C3=BCr=20jedes=20eingeene=20g?= =?UTF-8?q?er=C3=A4t=20hinzugef=C3=BCgt=20gruppen=20und=20auf=20ipad=20nur?= =?UTF-8?q?=20benachrichtungston,=20browser=20mod=20wieder=20entfernt,=20d?= =?UTF-8?q?a=20tts=20sowieso=20nicht=20geht?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 37 +- node-red/flows/smoke_detector.json | 334 ++++--- node-red/settings.html | 1458 +++++++++++++++++----------- 3 files changed, 1119 insertions(+), 710 deletions(-) diff --git a/README.md b/README.md index 98cff9b..8420af1 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,18 @@ # Rauchmelder-Benachrichtigungen für Home Assistant -**Node-RED Lösung mit integrierter Settings-Seite** +**Node-RED Lösung mit Multi-Gruppen-Support** ## Features -- **Eine Settings-Seite** - Alles zentral konfigurieren (auch als Dashboard-Karte) +- **Gruppen-System** - Mehrere Häuser/Bereiche komplett isoliert (z.B. Haus 1, Haus 2) +- **Alarm nur für betroffene Gruppe** - Rauchmelder in Gruppe A benachrichtigt nur Geräte in Gruppe A +- **STOPP pro Gruppe** - Benachrichtigungen und Sirenen separat pro Gruppe stoppen +- **Settings-Seite** - Alles zentral konfigurieren (auch als Dashboard-Karte) - **KEIN Helper nötig** - Kein YAML editieren, keine Entities erstellen - **Sirene pro Rauchmelder** - Jeder Rauchmelder kann seine eigene Sirene haben - **Dropdown-Auswahl** - Entities werden automatisch aus Home Assistant geladen -- **iOS & Android** - Unterschiedliche TTS-Methoden -- **STOPP pro Gerät** - Individuelles Stoppen +- **iOS Critical Alerts** - Laute Benachrichtigungen auch bei Stumm-Modus +- **Android TTS** - Sprachausgabe über Alarm-Stream - **Visuelles Debugging** - Node-RED Debug-Panel --- @@ -114,15 +117,27 @@ aspect_ratio: 100% ## Settings-Seite -Die Settings-Seite bietet: +Die Settings-Seite bietet **Gruppen** - jede Gruppe enthält: | Bereich | Optionen | |---------|----------| +| **Gruppen-Header** | Name der Gruppe (z.B. "Haus 1", "Ferienhaus"), Umbenennen, Löschen | | **Benachrichtigungen** | Text mit `{detector}` Platzhalter, TTS Intervall (5-60s), Sprache | | **Rauchmelder** | Dropdown-Auswahl der Rauchmelder-Sensoren aus HA | | **Sirene** | Pro Rauchmelder optional eine Sirene/Switch zuweisen | | **Namen** | Optionaler Anzeigename für jeden Rauchmelder | | **Geräte** | Android (Name + ID + TTS) oder iOS (Name + ID + Critical Alert) | +| **Notfall-Buttons** | Benachrichtigungen stoppen / Sirenen ausschalten - **nur für diese Gruppe** | + +### Gruppen-Konzept + +- **Gruppe "Haus 1"**: Rauchmelder Küche, Flur → benachrichtigt Stefan, Maria +- **Gruppe "Ferienhaus"**: Rauchmelder Wohnzimmer → benachrichtigt nur Stefan + +Wenn der Rauchmelder im Ferienhaus auslöst: +- Nur Stefan bekommt Benachrichtigungen (nicht Maria) +- Nur die Sirene im Ferienhaus geht an +- Der STOPP-Button stoppt nur die Ferienhaus-Benachrichtigungen --- @@ -159,13 +174,13 @@ Für iOS-Geräte werden **Critical Alerts** verwendet. Diese sind laute Benachri ## So funktioniert's 1. **Trigger:** Rauchmelder geht auf `on` -2. **Filter:** Ist es ein konfigurierter Rauchmelder? -3. **Sirene:** Die zugehörige Sirene des Rauchmelders wird aktiviert -4. **Benachrichtigungen:** Jedes Gerät bekommt: +2. **Gruppen-Filter:** In welcher Gruppe ist dieser Rauchmelder? +3. **Sirene:** Die zugehörige Sirene des Rauchmelders wird aktiviert (nur in dieser Gruppe) +4. **Benachrichtigungen:** Nur Geräte **dieser Gruppe** bekommen: - Text-Notification mit STOPP-Button - - TTS-Loop (alle X Sekunden) -5. **STOPP:** User drückt Button → TTS stoppt für dieses Gerät -6. **Rauch weg:** Alle Sirenen werden ausgeschaltet + - TTS-Loop / Critical Alert (alle X Sekunden) +5. **STOPP:** User drückt Button → TTS stoppt für dieses Gerät in dieser Gruppe +6. **Rauch weg:** Nur Sirenen dieser Gruppe werden ausgeschaltet **Die Config wird als JSON-Datei gespeichert (`/config/www/smoke_config.json`) und bleibt auch nach Neustarts erhalten.** diff --git a/node-red/flows/smoke_detector.json b/node-red/flows/smoke_detector.json index 3b4d57d..62d4c25 100644 --- a/node-red/flows/smoke_detector.json +++ b/node-red/flows/smoke_detector.json @@ -4,15 +4,15 @@ "type": "tab", "label": "Rauchmelder System", "disabled": false, - "info": "Rauchmelder-Benachrichtigungen V7\n\n✅ Config wird als JSON-Datei gespeichert\n✅ Persistiert über Neustarts" + "info": "Rauchmelder-Benachrichtigungen V8 - Gruppen-Support\n\n✅ Mehrere Gruppen (z.B. Haus 1, Haus 2)\n✅ Alarm nur für Geräte der betroffenen Gruppe\n✅ STOPP pro Gruppe" }, { "id": "comment_header", "type": "comment", "z": "smoke_flow_tab", - "name": "🔥 Rauchmelder V7 - Config in /config/www/smoke_config.json", - "info": "Config wird als JSON-Datei gespeichert.\nSettings-Seite lädt und speichert Config aus/in der Datei.", - "x": 300, + "name": "🔥 Rauchmelder V8 - Multi-Gruppen Support", + "info": "Jede Gruppe hat eigene Rauchmelder, Sirenen und Geräte.\nAlarm in Gruppe A benachrichtigt nur Geräte in Gruppe A.", + "x": 250, "y": 40, "wires": [] }, @@ -77,15 +77,15 @@ "id": "func_parse_config", "type": "function", "z": "smoke_flow_tab", - "name": "📋 Config parsen & laden", - "func": "// =====================================================\n// PARSE UND LADE CONFIG AUS JSON DATEI - V7\n// =====================================================\n\nconst ha = global.get('homeassistant.homeAssistant.states');\nif (!ha) {\n node.error('Home Assistant nicht verbunden!');\n node.status({fill:'red', shape:'ring', text:'HA nicht verbunden!'});\n return null;\n}\n\nlet config;\n\n// Prüfe ob Datei gelesen wurde oder Fehler\nif (msg.error) {\n // Datei existiert nicht - warte auf erste Config\n node.warn('Config-Datei nicht gefunden. Warte auf erste Speicherung über Settings-Seite.');\n node.status({fill:'yellow', shape:'ring', text:'⚠️ Keine Config - Bitte in Settings speichern'});\n \n // Leere Default-Config setzen\n config = {\n devices: [],\n detectors: [],\n notification_text: '🔥 RAUCHMELDER ALARM! {detector} hat Rauch erkannt!',\n tts_interval: 10,\n tts_language: 'de'\n };\n global.set('smoke_config', config);\n return null;\n}\n\ntry {\n config = JSON.parse(msg.payload);\n} catch (e) {\n node.error('Config JSON ungültig: ' + e.message);\n node.status({fill:'red', shape:'ring', text:'❌ Config JSON ungültig!'});\n return null;\n}\n\n// Config validieren und aufbereiten\nconfig = {\n devices: config.devices || [],\n detectors: config.detectors || [],\n notification_text: config.notification_text || '🔥 RAUCHMELDER ALARM! {detector} hat Rauch erkannt!',\n tts_interval: config.tts_interval || 10,\n tts_language: config.tts_language || 'de'\n};\n\n// Config global speichern\nglobal.set('smoke_config', config);\n\n// TTS-Status für alle Geräte initialisieren\nconfig.devices.forEach(device => {\n const key = `tts_active_${device.device_id}`;\n if (flow.get(key) === undefined) {\n flow.set(key, false);\n }\n});\n\n// Status anzeigen\nconst statusText = `✅ ${config.detectors.length} RM, ${config.devices.length} Geräte`;\nnode.status({fill:'green', shape:'dot', text: statusText});\n\nif (config.devices.length === 0 || config.detectors.length === 0) {\n node.warn('Config unvollständig! Bitte in Settings-Seite konfigurieren.');\n node.status({fill:'yellow', shape:'ring', text:'⚠️ Config unvollständig'});\n}\n\nnode.log('Config geladen: ' + statusText);\n\nmsg.config = config;\nreturn msg;", + "name": "📋 Config parsen (V8 Gruppen)", + "func": "// =====================================================\n// PARSE CONFIG MIT GRUPPEN-SUPPORT - V8\n// =====================================================\n\nconst ha = global.get('homeassistant.homeAssistant.states');\nif (!ha) {\n node.error('Home Assistant nicht verbunden!');\n node.status({fill:'red', shape:'ring', text:'HA nicht verbunden!'});\n return null;\n}\n\nlet config;\n\n// Prüfe ob Datei gelesen wurde oder Fehler\nif (msg.error) {\n node.warn('Config-Datei nicht gefunden. Warte auf erste Speicherung.');\n node.status({fill:'yellow', shape:'ring', text:'⚠️ Keine Config'});\n config = { groups: [] };\n global.set('smoke_config', config);\n return null;\n}\n\ntry {\n config = JSON.parse(msg.payload);\n} catch (e) {\n node.error('Config JSON ungültig: ' + e.message);\n node.status({fill:'red', shape:'ring', text:'❌ JSON ungültig!'});\n return null;\n}\n\n// Migration: Alte Config ohne groups\nif (!config.groups && (config.devices || config.detectors)) {\n node.log('Migriere alte Config zu Gruppen-Struktur...');\n config = {\n groups: [{\n id: 1,\n name: 'Zuhause',\n notification_text: config.notification_text || '🔥 RAUCHMELDER ALARM! {detector} hat Rauch erkannt!',\n tts_interval: config.tts_interval || 10,\n tts_language: config.tts_language || 'de',\n detectors: config.detectors || [],\n devices: config.devices || []\n }]\n };\n}\n\n// Sicherstellen dass groups existiert\nconfig.groups = config.groups || [];\n\n// Config global speichern\nglobal.set('smoke_config', config);\n\n// TTS-Status für alle Geräte in allen Gruppen initialisieren\nconfig.groups.forEach(group => {\n (group.devices || []).forEach(device => {\n const key = `tts_active_${group.id}_${device.device_id}`;\n if (flow.get(key) === undefined) {\n flow.set(key, false);\n }\n });\n});\n\n// Statistik\nconst totalDetectors = config.groups.reduce((sum, g) => sum + (g.detectors?.length || 0), 0);\nconst totalDevices = config.groups.reduce((sum, g) => sum + (g.devices?.length || 0), 0);\n\nconst statusText = `✅ ${config.groups.length} Gruppen, ${totalDetectors} RM, ${totalDevices} Geräte`;\nnode.status({fill:'green', shape:'dot', text: statusText});\n\nif (config.groups.length === 0) {\n node.warn('Keine Gruppen konfiguriert!');\n node.status({fill:'yellow', shape:'ring', text:'⚠️ Keine Gruppen'});\n}\n\nnode.log('Config geladen: ' + statusText);\n\nmsg.config = config;\nreturn msg;", "outputs": 1, "timeout": "", "noerr": 0, "initialize": "", "finalize": "", "libs": [], - "x": 650, + "x": 680, "y": 140, "wires": [["debug_config"]] }, @@ -112,7 +112,7 @@ "type": "function", "z": "smoke_flow_tab", "name": "💾 Config vorbereiten", - "func": "// Bereite Config zum Speichern vor\nconst eventData = msg.payload;\n\n// Debug: Was kommt an?\nnode.warn('Event empfangen: ' + JSON.stringify(eventData).substring(0, 200));\n\n// Das Event kann verschiedene Formate haben\nlet config = null;\n\nif (eventData && eventData.config) {\n // Format: { config: {...} }\n config = eventData.config;\n} else if (eventData && eventData.event && eventData.event.config) {\n // Format: { event: { config: {...} } }\n config = eventData.event.config;\n} else if (eventData && eventData.detectors) {\n // Format: Config direkt im payload\n config = eventData;\n}\n\nif (!config) {\n node.warn('Keine Config im Event gefunden! Struktur: ' + Object.keys(eventData || {}).join(', '));\n node.status({fill:'red', shape:'ring', text:'❌ Keine Config!'});\n return null;\n}\n\n// Config aufbereiten\nconst saveConfig = {\n devices: config.devices || [],\n detectors: config.detectors || [],\n notification_text: config.notification_text || '🔥 RAUCHMELDER ALARM! {detector} hat Rauch erkannt!',\n tts_interval: config.tts_interval || 10,\n tts_language: config.tts_language || 'de'\n};\n\n// Als JSON formatieren (schön lesbar)\nmsg.payload = JSON.stringify(saveConfig, null, 2);\n\nnode.status({fill:'blue', shape:'dot', text:'Speichere ' + saveConfig.detectors.length + ' RM, ' + saveConfig.devices.length + ' Geräte...'});\n\nreturn msg;", + "func": "const eventData = msg.payload;\n\nlet config = null;\n\nif (eventData && eventData.config) {\n config = eventData.config;\n} else if (eventData && eventData.event && eventData.event.config) {\n config = eventData.event.config;\n} else if (eventData && eventData.groups) {\n config = eventData;\n}\n\nif (!config) {\n node.warn('Keine Config im Event gefunden!');\n node.status({fill:'red', shape:'ring', text:'❌ Keine Config!'});\n return null;\n}\n\n// Sicherstellen dass groups existiert\nif (!config.groups) {\n config = { groups: [] };\n}\n\nmsg.payload = JSON.stringify(config, null, 2);\n\nconst totalGroups = config.groups?.length || 0;\nnode.status({fill:'blue', shape:'dot', text:'Speichere ' + totalGroups + ' Gruppen...'});\n\nreturn msg;", "outputs": 1, "timeout": "", "noerr": 0, @@ -143,7 +143,7 @@ "type": "function", "z": "smoke_flow_tab", "name": "✅ Gespeichert + Neu laden", - "func": "node.status({fill:'green', shape:'dot', text:'✅ Config gespeichert!'});\nnode.log('Config in /config/www/smoke_config.json gespeichert');\n\n// Trigger Neuladen der Config\nreturn msg;", + "func": "node.status({fill:'green', shape:'dot', text:'✅ Config gespeichert!'});\nnode.log('Config gespeichert');\nreturn msg;", "outputs": 1, "timeout": "", "noerr": 0, @@ -167,7 +167,7 @@ "targetType": "msg", "statusVal": "", "statusType": "auto", - "x": 870, + "x": 910, "y": 140, "wires": [] }, @@ -178,7 +178,7 @@ "name": "━━━━━━━━━━ RAUCHMELDER TRIGGER ━━━━━━━━━━", "info": "", "x": 220, - "y": 380, + "y": 300, "wires": [] }, { @@ -212,58 +212,58 @@ {"property": "data", "propertyType": "msg", "value": "", "valueType": "eventData"} ], "x": 160, - "y": 420, - "wires": [["func_filter_smoke"]] + "y": 340, + "wires": [["func_filter_smoke_groups"]] }, { - "id": "func_filter_smoke", + "id": "func_filter_smoke_groups", "type": "function", "z": "smoke_flow_tab", - "name": "🎯 Filter: Ist konfigurierter Rauchmelder?", - "func": "const config = global.get('smoke_config');\n\nif (!config || !config.detectors || config.detectors.length === 0) {\n return null;\n}\n\n// Entity ID aus den Event-Daten holen\nconst entityId = msg.data.entity_id || (msg.data.new_state ? msg.data.new_state.entity_id : null);\n\nif (!entityId) {\n node.warn('Keine entity_id gefunden!');\n return null;\n}\n\nconst detector = config.detectors.find(d => d.sensor === entityId);\nif (!detector) {\n return null;\n}\n\nconst newState = msg.payload;\nconst oldState = msg.data.old_state ? msg.data.old_state.state : 'off';\n\nmsg.entity_id = entityId;\n\nif (newState === 'on' && oldState !== 'on') {\n node.status({fill:'red', shape:'dot', text:'🔥 RAUCH: ' + entityId});\n msg.event = 'smoke_detected';\n msg.config = config;\n msg.detector = detector;\n return [msg, null];\n}\n\nif (newState === 'off' && oldState === 'on') {\n node.status({fill:'green', shape:'dot', text:'✅ Klar: ' + entityId});\n msg.event = 'smoke_cleared';\n msg.config = config;\n msg.detector = detector;\n return [null, msg];\n}\n\nreturn null;", + "name": "🎯 Filter: Welche Gruppe?", + "func": "// =====================================================\n// FINDE GRUPPE FÜR DIESEN RAUCHMELDER\n// =====================================================\n\nconst config = global.get('smoke_config');\n\nif (!config || !config.groups || config.groups.length === 0) {\n return null;\n}\n\nconst entityId = msg.data.entity_id || (msg.data.new_state ? msg.data.new_state.entity_id : null);\n\nif (!entityId) {\n return null;\n}\n\n// Finde die Gruppe, die diesen Sensor enthält\nlet foundGroup = null;\nlet foundDetector = null;\n\nfor (let group of config.groups) {\n const detector = (group.detectors || []).find(d => d.sensor === entityId);\n if (detector) {\n foundGroup = group;\n foundDetector = detector;\n break;\n }\n}\n\nif (!foundGroup) {\n // Sensor ist in keiner Gruppe konfiguriert\n return null;\n}\n\nconst newState = msg.payload;\nconst oldState = msg.data.old_state ? msg.data.old_state.state : 'off';\n\nmsg.entity_id = entityId;\nmsg.group = foundGroup;\nmsg.detector = foundDetector;\nmsg.config = config;\n\nif (newState === 'on' && oldState !== 'on') {\n node.status({fill:'red', shape:'dot', text:'🔥 ' + foundGroup.name + ': ' + entityId});\n msg.event = 'smoke_detected';\n return [msg, null];\n}\n\nif (newState === 'off' && oldState === 'on') {\n node.status({fill:'green', shape:'dot', text:'✅ ' + foundGroup.name + ': ' + entityId});\n msg.event = 'smoke_cleared';\n return [null, msg];\n}\n\nreturn null;", "outputs": 2, "timeout": "", "noerr": 0, "initialize": "", "finalize": "", "libs": [], - "x": 460, + "x": 430, "y": 340, - "wires": [["func_process_alarm"], ["func_check_all_clear"]], + "wires": [["func_process_alarm_group"], ["func_check_group_clear"]], "outputLabels": ["Rauch erkannt", "Rauch weg"] }, { - "id": "func_process_alarm", + "id": "func_process_alarm_group", "type": "function", "z": "smoke_flow_tab", - "name": "🚨 Verarbeite Alarm", - "func": "const config = msg.config;\nconst detector = msg.detector;\nconst triggered = msg.entity_id;\n\nlet detector_name = detector.name;\nif (!detector_name && msg.data.new_state && msg.data.new_state.attributes) {\n detector_name = msg.data.new_state.attributes.friendly_name;\n}\nif (!detector_name) {\n detector_name = triggered.replace('binary_sensor.', '').replace(/_/g, ' ');\n}\n\nconst message = config.notification_text.replace(/{detector}/g, detector_name);\n\nmsg.triggered_detector = triggered;\nmsg.detector_name = detector_name;\nmsg.message = message;\n\nnode.status({fill:'red', shape:'dot', text:'🔥 ' + detector_name});\n\nreturn msg;", + "name": "🚨 Alarm für Gruppe", + "func": "const group = msg.group;\nconst detector = msg.detector;\nconst triggered = msg.entity_id;\n\n// Detector Name ermitteln\nlet detector_name = detector.name;\nif (!detector_name && msg.data.new_state && msg.data.new_state.attributes) {\n detector_name = msg.data.new_state.attributes.friendly_name;\n}\nif (!detector_name) {\n detector_name = triggered.replace('binary_sensor.', '').replace(/_/g, ' ');\n}\n\nconst message = group.notification_text.replace(/{detector}/g, detector_name);\n\nmsg.triggered_detector = triggered;\nmsg.detector_name = detector_name;\nmsg.message = message;\n\nnode.status({fill:'red', shape:'dot', text:'🔥 ' + group.name + ': ' + detector_name});\n\nreturn msg;", "outputs": 1, "timeout": "", "noerr": 0, "initialize": "", "finalize": "", "libs": [], - "x": 770, + "x": 710, "y": 320, - "wires": [["func_turn_on_alarms"]] + "wires": [["func_turn_on_group_siren"]] }, { - "id": "func_turn_on_alarms", + "id": "func_turn_on_group_siren", "type": "function", "z": "smoke_flow_tab", - "name": "📢 Sirene EIN", - "func": "const config = msg.config;\nconst detector = msg.detector;\n\nconst siren = detector.siren;\n\nif (!siren) {\n node.warn('Keine Sirene für ' + detector.sensor + ' konfiguriert');\n return [null, msg];\n}\n\nconst alarmMsg = {\n payload: {\n data: {\n entity_id: siren\n }\n }\n};\n\nnode.status({fill:'red', shape:'dot', text:'Sirene: ' + siren});\n\nreturn [[alarmMsg], msg];", + "name": "📢 Gruppen-Sirene EIN", + "func": "const group = msg.group;\nconst detector = msg.detector;\n\nconst siren = detector.siren;\n\nif (!siren) {\n node.status({fill:'yellow', shape:'dot', text:'Keine Sirene für ' + detector.sensor});\n return [null, msg];\n}\n\nconst alarmMsg = {\n payload: {\n data: {\n entity_id: siren\n }\n }\n};\n\nnode.status({fill:'red', shape:'dot', text:'Sirene: ' + siren});\n\nreturn [[alarmMsg], msg];", "outputs": 2, "timeout": "", "noerr": 0, "initialize": "", "finalize": "", "libs": [], - "x": 990, - "y": 300, - "wires": [["ha_switch_on"], ["func_notify_all_devices"]], - "outputLabels": ["Alarme", "Weiter"] + "x": 940, + "y": 320, + "wires": [["ha_switch_on"], ["func_notify_group_devices"]], + "outputLabels": ["Sirene", "Weiter"] }, { "id": "ha_switch_on", @@ -284,73 +284,115 @@ "mustacheAltTags": false, "outputProperties": [], "queue": "none", - "x": 1180, - "y": 280, + "x": 1160, + "y": 300, "wires": [[]] }, { "id": "comment_notifications", "type": "comment", "z": "smoke_flow_tab", - "name": "━━━━━━━━━━ BENACHRICHTIGUNGEN ━━━━━━━━━━", + "name": "━━━━━━━━━━ BENACHRICHTIGUNGEN (NUR GRUPPEN-GERÄTE) ━━━━━━━━━━", "info": "", - "x": 220, - "y": 480, + "x": 300, + "y": 400, "wires": [] }, { - "id": "func_notify_all_devices", + "id": "func_notify_group_devices", "type": "function", "z": "smoke_flow_tab", - "name": "📱 Für jedes Gerät", - "func": "const config = msg.config;\nconst devices = config.devices || [];\n\nif (devices.length === 0) {\n node.warn('Keine Geräte konfiguriert!');\n return null;\n}\n\nconst messages = [];\n\nfor (let device of devices) {\n const ttsKey = `tts_active_${device.device_id}`;\n const isActive = flow.get(ttsKey);\n \n if (isActive) {\n node.log('TTS bereits aktiv für: ' + device.device_name);\n continue;\n }\n \n const newMsg = RED.util.cloneMessage(msg);\n newMsg.device = device;\n messages.push(newMsg);\n}\n\nif (messages.length === 0) {\n node.status({fill:'yellow', shape:'dot', text:'Alle Geräte bereits aktiv'});\n return null;\n}\n\nnode.status({fill:'blue', shape:'dot', text: messages.length + ' Geräte benachrichtigen'});\n\nreturn [messages];", + "name": "📱 Nur Geräte dieser Gruppe", + "func": "// =====================================================\n// NUR GERÄTE DER BETROFFENEN GRUPPE BENACHRICHTIGEN\n// =====================================================\n\nconst group = msg.group;\nconst devices = group.devices || [];\n\nif (devices.length === 0) {\n node.warn('Keine Geräte in Gruppe \"' + group.name + '\" konfiguriert!');\n node.status({fill:'yellow', shape:'dot', text:'Keine Geräte in ' + group.name});\n return null;\n}\n\nconst messages = [];\n\nfor (let device of devices) {\n // TTS-Key enthält jetzt auch die Group ID\n const ttsKey = `tts_active_${group.id}_${device.device_id}`;\n const isActive = flow.get(ttsKey);\n \n if (isActive) {\n node.log('TTS bereits aktiv für: ' + device.device_name + ' in Gruppe ' + group.name);\n continue;\n }\n \n const newMsg = RED.util.cloneMessage(msg);\n newMsg.device = device;\n messages.push(newMsg);\n}\n\nif (messages.length === 0) {\n node.status({fill:'yellow', shape:'dot', text:'Alle Geräte in ' + group.name + ' bereits aktiv'});\n return null;\n}\n\nnode.status({fill:'blue', shape:'dot', text: group.name + ': ' + messages.length + ' Geräte'});\n\nreturn [messages];", "outputs": 1, "timeout": "", "noerr": 0, "initialize": "", "finalize": "", "libs": [], - "x": 170, - "y": 540, - "wires": [["func_mark_active"]] + "x": 190, + "y": 460, + "wires": [["func_mark_group_active"]] }, { - "id": "func_mark_active", + "id": "func_mark_group_active", "type": "function", "z": "smoke_flow_tab", "name": "✅ Markiere als aktiv", - "func": "const device = msg.device;\nconst ttsKey = `tts_active_${device.device_id}`;\n\nflow.set(ttsKey, true);\n\nnode.status({fill:'green', shape:'dot', text: '✅ ' + device.device_name});\n\nreturn msg;", + "func": "const device = msg.device;\nconst group = msg.group;\n\n// TTS-Key mit Group ID\nconst ttsKey = `tts_active_${group.id}_${device.device_id}`;\nflow.set(ttsKey, true);\n\nnode.status({fill:'green', shape:'dot', text: '✅ ' + group.name + ': ' + device.device_name});\n\nreturn msg;", "outputs": 1, "timeout": "", "noerr": 0, "initialize": "", "finalize": "", "libs": [], - "x": 400, - "y": 540, - "wires": [["func_send_text_notification"]] + "x": 460, + "y": 460, + "wires": [["func_send_group_notification"]] }, { - "id": "func_send_text_notification", + "id": "func_send_group_notification", "type": "function", "z": "smoke_flow_tab", - "name": "📝 Text-Notification erstellen", - "func": "const device = msg.device;\nconst message = msg.message;\nconst triggered = msg.triggered_detector;\n\n// Device ID direkt verwenden für den Service-Namen\nconst serviceName = 'mobile_app_' + device.device_id;\n\nconst tag = 'smoke_alarm_' + triggered.replace(/\\./g, '_');\n// Action ID enthält unsere device_id für späteres Mapping\nconst actionId = 'STOP_SMOKE_' + device.device_id;\n\nmsg.notify_service = serviceName;\nmsg.notify_message = message;\nmsg.notify_title = '🚨 RAUCHMELDER ALARM';\nmsg.notify_tag = tag;\nmsg.notify_action_id = actionId;\n\nmsg.notification_tag = tag;\nmsg.service_name = serviceName;\nmsg.our_device_id = device.device_id;\n\nnode.status({fill:'green', shape:'dot', text: serviceName});\n\nreturn msg;", + "name": "📝 Gruppen-Notification", + "func": "const device = msg.device;\nconst group = msg.group;\nconst message = msg.message;\nconst triggered = msg.triggered_detector;\n\nconst serviceName = 'mobile_app_' + device.device_id;\n\n// Tag enthält Group ID für späteres Löschen\nconst tag = 'smoke_alarm_' + group.id + '_' + triggered.replace(/\\./g, '_');\n\n// Action ID enthält Group ID und Device ID\nconst actionId = 'STOP_SMOKE_' + group.id + '_' + device.device_id;\n\nmsg.notify_service = serviceName;\nmsg.notify_message = message;\nmsg.notify_title = '🚨 ' + group.name + ' - RAUCHMELDER ALARM';\nmsg.notify_tag = tag;\nmsg.notify_action_id = actionId;\n\nmsg.notification_tag = tag;\nmsg.service_name = serviceName;\nmsg.our_device_id = device.device_id;\n\nnode.status({fill:'green', shape:'dot', text: group.name + ': ' + serviceName});\n\nreturn msg;", "outputs": 1, "timeout": "", "noerr": 0, "initialize": "", "finalize": "", "libs": [], - "x": 680, - "y": 540, - "wires": [["ha_notify", "func_start_tts_loop"]] + "x": 720, + "y": 460, + "wires": [["switch_notify_platform", "func_start_group_tts"]] }, { - "id": "ha_notify", + "id": "switch_notify_platform", + "type": "switch", + "z": "smoke_flow_tab", + "name": "📱 iOS / Android?", + "property": "device.is_ios", + "propertyType": "msg", + "rules": [ + {"t": "true"}, + {"t": "else"} + ], + "checkall": "true", + "repair": false, + "outputs": 2, + "x": 170, + "y": 520, + "wires": [["ha_notify_ios"], ["ha_notify_android"]], + "outputLabels": ["iOS", "Android"] + }, + { + "id": "ha_notify_ios", "type": "api-call-service", "z": "smoke_flow_tab", - "name": "📱 Benachrichtigung senden", + "name": "🍎 iOS Notification", + "server": "", + "version": 5, + "debugenabled": false, + "domain": "notify", + "service": "{{notify_service}}", + "areaId": [], + "deviceId": [], + "entityId": [], + "data": "{\"message\": \"{{notify_message}}\\n\\n👆 Lange drücken für STOPP\", \"title\": \"{{notify_title}}\", \"data\": {\"tag\": \"{{notify_tag}}\", \"push\": {\"sound\": {\"name\": \"default\", \"critical\": 0, \"volume\": 0.5}, \"interruption-level\": \"time-sensitive\"}, \"presentation_options\": [\"alert\", \"badge\"], \"actions\": [{\"action\": \"SILENCE\", \"title\": \"✓ OK\", \"authenticationRequired\": false}, {\"action\": \"{{notify_action_id}}\", \"title\": \"🛑 STOPP\", \"destructive\": true}]}}", + "dataType": "json", + "mergeContext": "", + "mustacheAltTags": false, + "outputProperties": [], + "queue": "none", + "x": 420, + "y": 500, + "wires": [[]] + }, + { + "id": "ha_notify_android", + "type": "api-call-service", + "z": "smoke_flow_tab", + "name": "🤖 Android Notification", "server": "", "version": 5, "debugenabled": false, @@ -365,8 +407,8 @@ "mustacheAltTags": false, "outputProperties": [], "queue": "none", - "x": 200, - "y": 600, + "x": 430, + "y": 540, "wires": [[]] }, { @@ -376,39 +418,39 @@ "name": "━━━━━━━━━━ TTS LOOP ━━━━━━━━━━", "info": "", "x": 190, - "y": 660, + "y": 580, "wires": [] }, { - "id": "func_start_tts_loop", + "id": "func_start_group_tts", "type": "function", "z": "smoke_flow_tab", - "name": "🔊 TTS Loop starten", - "func": "const device = msg.device;\nconst config = msg.config;\n\nmsg.tts_device = device;\nmsg.tts_config = config;\nmsg.loop_count = 0;\n\nnode.status({fill:'blue', shape:'dot', text:'TTS Start: ' + device.device_name});\n\nreturn msg;", + "name": "🔊 Gruppen-TTS starten", + "func": "const device = msg.device;\nconst group = msg.group;\n\nmsg.tts_device = device;\nmsg.tts_group = group;\nmsg.loop_count = 0;\n\n// TTS Intervall aus Gruppe\nmsg.delay = (group.tts_interval || 10) * 1000;\n\nnode.status({fill:'blue', shape:'dot', text: group.name + ': TTS Start ' + device.device_name});\n\nreturn msg;", "outputs": 1, "timeout": "", "noerr": 0, "initialize": "", "finalize": "", "libs": [], - "x": 170, - "y": 720, - "wires": [["func_check_tts_active"]] + "x": 180, + "y": 640, + "wires": [["func_check_group_tts_active"]] }, { - "id": "func_check_tts_active", + "id": "func_check_group_tts_active", "type": "function", "z": "smoke_flow_tab", - "name": "🔄 TTS noch aktiv?", - "func": "const device = msg.tts_device;\nconst ttsKey = `tts_active_${device.device_id}`;\nconst isActive = flow.get(ttsKey);\n\nif (!isActive) {\n node.status({fill:'green', shape:'dot', text:'TTS gestoppt: ' + device.device_name});\n return null;\n}\n\nmsg.loop_count = (msg.loop_count || 0) + 1;\nnode.status({fill:'blue', shape:'ring', text:'TTS #' + msg.loop_count + ': ' + device.device_name});\n\nreturn msg;", + "name": "🔄 Gruppen-TTS aktiv?", + "func": "const device = msg.tts_device;\nconst group = msg.tts_group;\n\nif (!device || !group) {\n node.warn('TTS Check: device oder group fehlt!');\n return null;\n}\n\n// TTS-Key mit Group ID prüfen\nconst ttsKey = `tts_active_${group.id}_${device.device_id}`;\nconst isActive = flow.get(ttsKey);\n\nnode.log('TTS Check - Key: ' + ttsKey + ', Active: ' + isActive);\n\nif (!isActive) {\n node.status({fill:'green', shape:'dot', text: group.name + ': TTS gestoppt ' + device.device_name});\n node.log('TTS Loop beendet für ' + device.device_name);\n return null;\n}\n\nmsg.loop_count = (msg.loop_count || 0) + 1;\nnode.status({fill:'blue', shape:'ring', text: group.name + ' #' + msg.loop_count + ': ' + device.device_name});\n\nreturn msg;", "outputs": 1, "timeout": "", "noerr": 0, "initialize": "", "finalize": "", "libs": [], - "x": 410, - "y": 720, + "x": 440, + "y": 640, "wires": [["switch_platform"]] }, { @@ -425,8 +467,8 @@ "checkall": "true", "repair": false, "outputs": 2, - "x": 650, - "y": 720, + "x": 690, + "y": 640, "wires": [["func_tts_ios"], ["func_tts_android"]], "outputLabels": ["iOS", "Android"] }, @@ -435,7 +477,7 @@ "type": "function", "z": "smoke_flow_tab", "name": "🍎 iOS Critical Alert", - "func": "const device = msg.tts_device;\n\n// iOS Critical Alert mit Alarm-Sound\nconst serviceName = 'mobile_app_' + device.device_id;\n\nmsg.notify_service = serviceName;\nmsg.tts_message = msg.message;\n\nnode.status({fill:'red', shape:'dot', text: 'Critical: ' + device.device_name});\n\nreturn msg;", + "func": "const device = msg.tts_device;\nconst group = msg.tts_group;\n\nconst serviceName = 'mobile_app_' + device.device_id;\n\nmsg.notify_service = serviceName;\nmsg.tts_message = msg.message;\nmsg.tts_title = '🚨 ' + group.name + ' - ALARM';\n\nnode.status({fill:'red', shape:'dot', text: group.name + ': Critical ' + device.device_name});\n\nreturn msg;", "outputs": 1, "timeout": "", "noerr": 0, @@ -443,7 +485,7 @@ "finalize": "", "libs": [], "x": 190, - "y": 800, + "y": 720, "wires": [["ha_tts_ios", "delay_tts"]] }, { @@ -459,14 +501,14 @@ "areaId": [], "deviceId": [], "entityId": [], - "data": "{\"message\": \"{{tts_message}}\", \"title\": \"🚨 RAUCHMELDER ALARM\", \"data\": {\"push\": {\"sound\": {\"name\": \"default\", \"critical\": 1, \"volume\": 1.0}}, \"tag\": \"smoke_critical\"}}", + "data": "{\"message\": \"{{tts_message}}\", \"title\": \"{{tts_title}}\", \"data\": {\"push\": {\"sound\": {\"name\": \"default\", \"critical\": 1, \"volume\": 1.0}}, \"tag\": \"smoke_critical\"}}", "dataType": "json", "mergeContext": "", "mustacheAltTags": false, "outputProperties": [], "queue": "none", "x": 440, - "y": 800, + "y": 720, "wires": [["delay_clear_ios"]] }, { @@ -487,7 +529,7 @@ "allowrate": false, "outputs": 1, "x": 640, - "y": 800, + "y": 720, "wires": [["ha_clear_ios"]] }, { @@ -510,7 +552,7 @@ "outputProperties": [], "queue": "none", "x": 830, - "y": 800, + "y": 720, "wires": [[]] }, { @@ -518,7 +560,7 @@ "type": "function", "z": "smoke_flow_tab", "name": "🤖 Android TTS", - "func": "const device = msg.tts_device;\n\n// Device ID direkt verwenden (ist bereits im richtigen Format)\nconst serviceName = 'mobile_app_' + device.device_id;\n\nmsg.notify_service = serviceName;\nmsg.tts_text = msg.message;\n\nnode.status({fill:'blue', shape:'dot', text: serviceName});\n\nreturn msg;", + "func": "const device = msg.tts_device;\nconst group = msg.tts_group;\n\nconst serviceName = 'mobile_app_' + device.device_id;\n\nmsg.notify_service = serviceName;\nmsg.tts_text = msg.message;\n\nnode.status({fill:'blue', shape:'dot', text: group.name + ': ' + serviceName});\n\nreturn msg;", "outputs": 1, "timeout": "", "noerr": 0, @@ -526,7 +568,7 @@ "finalize": "", "libs": [], "x": 180, - "y": 860, + "y": 780, "wires": [["ha_tts_android", "delay_tts"]] }, { @@ -549,7 +591,7 @@ "outputProperties": [], "queue": "none", "x": 400, - "y": 860, + "y": 780, "wires": [[]] }, { @@ -570,17 +612,17 @@ "allowrate": false, "outputs": 1, "x": 640, - "y": 830, - "wires": [["func_check_tts_active"]] + "y": 750, + "wires": [["func_check_group_tts_active"]] }, { "id": "comment_stop", "type": "comment", "z": "smoke_flow_tab", - "name": "━━━━━━━━━━ STOPP BUTTON ━━━━━━━━━━", + "name": "━━━━━━━━━━ STOPP BUTTON (PRO GRUPPE) ━━━━━━━━━━", "info": "", - "x": 200, - "y": 940, + "x": 250, + "y": 860, "wires": [] }, { @@ -598,55 +640,55 @@ {"property": "payload", "propertyType": "msg", "value": "", "valueType": "eventData"} ], "x": 180, - "y": 1000, - "wires": [["func_check_stop_action"]] + "y": 920, + "wires": [["func_check_group_stop"]] }, { - "id": "func_check_stop_action", + "id": "func_check_group_stop", "type": "function", "z": "smoke_flow_tab", - "name": "🔍 STOP_SMOKE prüfen", - "func": "const action = msg.payload.event.action;\n\nif (!action || !action.startsWith('STOP_SMOKE_')) {\n return null;\n}\n\n// Device ID aus der Action extrahieren (STOP_SMOKE_one_plus_7_pro_stefan -> one_plus_7_pro_stefan)\nconst ourDeviceId = action.replace('STOP_SMOKE_', '');\n\nif (!ourDeviceId) {\n node.warn('Keine Device ID in Action gefunden: ' + action);\n return null;\n}\n\nmsg.stop_device_id = ourDeviceId;\n\nnode.status({fill:'yellow', shape:'dot', text:'STOPP für: ' + ourDeviceId});\nnode.log('STOPP Action: ' + action + ' -> Device ID: ' + ourDeviceId);\n\nreturn msg;", + "name": "🔍 STOP_SMOKE Gruppe prüfen", + "func": "// =====================================================\n// STOPP NUR FÜR DIESE GRUPPE + GERÄT\n// =====================================================\n\nnode.log('STOPP Event empfangen: ' + JSON.stringify(msg.payload));\n\nconst action = msg.payload.event ? msg.payload.event.action : msg.payload.action;\n\nnode.log('Action: ' + action);\n\n// Format: STOP_SMOKE_{groupId}_{deviceId}\nif (!action || !action.startsWith('STOP_SMOKE_')) {\n node.log('Ignoriere Action (kein STOP_SMOKE_): ' + action);\n return null;\n}\n\n// Parse Group ID und Device ID\nconst parts = action.replace('STOP_SMOKE_', '').split('_');\nnode.log('Parts: ' + JSON.stringify(parts));\n\nif (parts.length < 2) {\n node.warn('Ungültiges Action-Format: ' + action);\n return null;\n}\n\n// Erste Zahl ist die Group ID, Rest ist Device ID\nconst groupId = parseInt(parts[0]);\nconst deviceId = parts.slice(1).join('_');\n\nnode.log('Parsed - GroupID: ' + groupId + ', DeviceID: ' + deviceId);\n\nif (isNaN(groupId)) {\n node.warn('Ungültige Group ID: ' + parts[0]);\n return null;\n}\n\nmsg.stop_group_id = groupId;\nmsg.stop_device_id = deviceId;\n\nnode.status({fill:'yellow', shape:'dot', text:'STOPP Gruppe ' + groupId + ': ' + deviceId});\n\nreturn msg;", "outputs": 1, "timeout": "", "noerr": 0, "initialize": "", "finalize": "", "libs": [], - "x": 440, - "y": 1000, - "wires": [["func_stop_tts"]] + "x": 470, + "y": 920, + "wires": [["func_stop_group_tts"]] }, { - "id": "func_stop_tts", + "id": "func_stop_group_tts", "type": "function", "z": "smoke_flow_tab", - "name": "⏹️ TTS stoppen", - "func": "const deviceId = msg.stop_device_id;\nconst config = global.get('smoke_config');\n\nconst device = config.devices.find(d => d.device_id === deviceId);\n\nif (!device) {\n node.warn('Gerät nicht gefunden: ' + deviceId);\n return null;\n}\n\nconst ttsKey = `tts_active_${deviceId}`;\nflow.set(ttsKey, false);\n\nnode.status({fill:'green', shape:'dot', text:'TTS gestoppt: ' + device.device_name});\n\nmsg.device = device;\nmsg.config = config;\n\nreturn msg;", + "name": "⏹️ Gruppen-TTS stoppen", + "func": "const groupId = msg.stop_group_id;\nconst deviceId = msg.stop_device_id;\nconst config = global.get('smoke_config');\n\nif (!config || !config.groups) {\n node.warn('Keine Config vorhanden!');\n return null;\n}\n\nconst group = config.groups.find(g => g.id === groupId);\nif (!group) {\n node.warn('Gruppe nicht gefunden: ' + groupId);\n node.warn('Verfügbare Gruppen: ' + config.groups.map(g => g.id + ':' + g.name).join(', '));\n return null;\n}\n\nconst device = group.devices.find(d => d.device_id === deviceId);\nif (!device) {\n node.warn('Gerät nicht in Gruppe gefunden: ' + deviceId);\n node.warn('Verfügbare Geräte: ' + group.devices.map(d => d.device_id).join(', '));\n return null;\n}\n\n// TTS-Key mit Group ID\nconst ttsKey = `tts_active_${groupId}_${deviceId}`;\nflow.set(ttsKey, false);\n\nnode.status({fill:'green', shape:'dot', text: group.name + ': TTS gestoppt ' + device.device_name});\nnode.log('TTS gestoppt für ' + device.device_name + ' in Gruppe ' + group.name + ' (Key: ' + ttsKey + ')');\n\nmsg.device = device;\nmsg.group = group;\n\nreturn msg;", "outputs": 1, "timeout": "", "noerr": 0, "initialize": "", "finalize": "", "libs": [], - "x": 680, - "y": 1000, - "wires": [["func_clear_notifications"]] + "x": 750, + "y": 920, + "wires": [["func_clear_group_notifications"]] }, { - "id": "func_clear_notifications", + "id": "func_clear_group_notifications", "type": "function", "z": "smoke_flow_tab", - "name": "🗑️ Notifications löschen", - "func": "const device = msg.device;\nconst config = msg.config;\n\n// Device ID direkt verwenden für den Service-Namen\nconst serviceName = 'mobile_app_' + device.device_id;\n\nconst messages = [];\n\nfor (let det of config.detectors) {\n const tag = 'smoke_alarm_' + det.sensor.replace(/\\./g, '_');\n \n messages.push({\n notify_service: serviceName,\n clear_tag: tag\n });\n}\n\nnode.status({fill:'green', shape:'dot', text: messages.length + ' Notifications gelöscht'});\n\nreturn [messages];", + "name": "🗑️ Gruppen-Notifications löschen", + "func": "const device = msg.device;\nconst group = msg.group;\n\nconst serviceName = 'mobile_app_' + device.device_id;\n\nconst messages = [];\n\nfor (let det of group.detectors) {\n // Tag enthält Group ID\n const tag = 'smoke_alarm_' + group.id + '_' + det.sensor.replace(/\\./g, '_');\n \n messages.push({\n notify_service: serviceName,\n clear_tag: tag\n });\n}\n\nnode.status({fill:'green', shape:'dot', text: group.name + ': ' + messages.length + ' Notifications gelöscht'});\n\nreturn [messages];", "outputs": 1, "timeout": "", "noerr": 0, "initialize": "", "finalize": "", "libs": [], - "x": 190, - "y": 1060, + "x": 230, + "y": 980, "wires": [["ha_clear_notification"]] }, { @@ -668,42 +710,42 @@ "mustacheAltTags": false, "outputProperties": [], "queue": "none", - "x": 440, - "y": 1060, + "x": 520, + "y": 980, "wires": [[]] }, { "id": "comment_clear", "type": "comment", "z": "smoke_flow_tab", - "name": "━━━━━━━━━━ RAUCH WEG ━━━━━━━━━━", + "name": "━━━━━━━━━━ RAUCH WEG (PRO GRUPPE) ━━━━━━━━━━", "info": "", - "x": 190, - "y": 1120, + "x": 250, + "y": 1040, "wires": [] }, { - "id": "func_check_all_clear", + "id": "func_check_group_clear", "type": "function", "z": "smoke_flow_tab", - "name": "🔍 Alle Rauchmelder klar?", - "func": "const config = msg.config;\nconst cleared = msg.entity_id;\nconst ha = global.get('homeassistant.homeAssistant.states');\n\nfor (let det of config.detectors) {\n if (det.sensor !== cleared) {\n const state = ha[det.sensor];\n if (state && state.state === 'on') {\n node.status({fill:'yellow', shape:'dot', text:'Noch aktiv: ' + det.sensor});\n return null;\n }\n }\n}\n\nnode.status({fill:'green', shape:'dot', text:'✅ Alle Rauchmelder klar'});\n\nreturn msg;", + "name": "🔍 Alle RM in Gruppe klar?", + "func": "// =====================================================\n// PRÜFE OB ALLE RAUCHMELDER IN DIESER GRUPPE KLAR SIND\n// =====================================================\n\nconst group = msg.group;\nconst cleared = msg.entity_id;\nconst ha = global.get('homeassistant.homeAssistant.states');\n\n// Prüfe nur Rauchmelder in dieser Gruppe\nfor (let det of group.detectors) {\n if (det.sensor !== cleared) {\n const state = ha[det.sensor];\n if (state && state.state === 'on') {\n node.status({fill:'yellow', shape:'dot', text: group.name + ': Noch aktiv ' + det.sensor});\n return null;\n }\n }\n}\n\nnode.status({fill:'green', shape:'dot', text:'✅ ' + group.name + ': Alle klar'});\n\nreturn msg;", "outputs": 1, "timeout": "", "noerr": 0, "initialize": "", "finalize": "", "libs": [], - "x": 780, + "x": 720, "y": 360, - "wires": [["func_turn_off_alarms"]] + "wires": [["func_turn_off_group_sirens"]] }, { - "id": "func_turn_off_alarms", + "id": "func_turn_off_group_sirens", "type": "function", "z": "smoke_flow_tab", - "name": "🔇 Alle Sirenen AUS", - "func": "const config = msg.config;\n\nconst sirens = config.detectors\n .filter(d => d.siren)\n .map(d => d.siren);\n\nif (sirens.length === 0) {\n return null;\n}\n\nconst uniqueSirens = [...new Set(sirens)];\n\nconst messages = uniqueSirens.map(siren => ({\n payload: {\n data: {\n entity_id: siren\n }\n }\n}));\n\nnode.status({fill:'green', shape:'dot', text: uniqueSirens.length + ' Sirenen deaktiviert'});\n\nreturn [messages];", + "name": "🔇 Gruppen-Sirenen AUS", + "func": "// =====================================================\n// NUR SIRENEN DIESER GRUPPE AUSSCHALTEN\n// =====================================================\n\nconst group = msg.group;\n\nconst sirens = (group.detectors || [])\n .filter(d => d.siren)\n .map(d => d.siren);\n\nif (sirens.length === 0) {\n return null;\n}\n\nconst uniqueSirens = [...new Set(sirens)];\n\nconst messages = uniqueSirens.map(siren => ({\n payload: {\n data: {\n entity_id: siren\n }\n }\n}));\n\nnode.status({fill:'green', shape:'dot', text: group.name + ': ' + uniqueSirens.length + ' Sirenen aus'});\n\nreturn [messages];", "outputs": 1, "timeout": "", "noerr": 0, @@ -711,7 +753,7 @@ "finalize": "", "libs": [], "x": 190, - "y": 1180, + "y": 1100, "wires": [["ha_switch_off"]] }, { @@ -733,20 +775,54 @@ "mustacheAltTags": false, "outputProperties": [], "queue": "none", - "x": 400, - "y": 1180, + "x": 440, + "y": 1100, "wires": [[]] }, { "id": "comment_emergency", "type": "comment", "z": "smoke_flow_tab", - "name": "━━━━━━━━━━ NOTFALL-STOPP ━━━━━━━━━━", + "name": "━━━━━━━━━━ NOTFALL-STOPP (PRO GRUPPE) ━━━━━━━━━━", "info": "", - "x": 200, - "y": 1240, + "x": 260, + "y": 1160, "wires": [] }, + { + "id": "event_stop_group", + "type": "server-events", + "z": "smoke_flow_tab", + "name": "🛑 STOPP GRUPPE Event", + "server": "", + "version": 2, + "eventType": "smoke_stop_group", + "exposeToHomeAssistant": false, + "haConfig": [], + "waitForRunning": true, + "outputProperties": [ + {"property": "payload", "propertyType": "msg", "value": "", "valueType": "eventData"} + ], + "x": 190, + "y": 1220, + "wires": [["func_stop_group_all_tts"]] + }, + { + "id": "func_stop_group_all_tts", + "type": "function", + "z": "smoke_flow_tab", + "name": "⏹️ Alle TTS in Gruppe stoppen", + "func": "// =====================================================\n// STOPPE ALLE TTS-LOOPS IN EINER BESTIMMTEN GRUPPE\n// =====================================================\n\nconst eventData = msg.payload;\nconst groupId = eventData.group_id || (eventData.event && eventData.event.group_id);\nconst groupName = eventData.group_name || (eventData.event && eventData.event.group_name) || 'Unbekannt';\n\nif (!groupId) {\n node.warn('Keine group_id im Event!');\n return null;\n}\n\nconst config = global.get('smoke_config');\n\nif (!config || !config.groups) {\n node.warn('Keine Config vorhanden!');\n return null;\n}\n\nconst group = config.groups.find(g => g.id === groupId);\nif (!group) {\n node.warn('Gruppe nicht gefunden: ' + groupId);\n return null;\n}\n\nlet stoppedCount = 0;\nfor (let device of group.devices) {\n const ttsKey = `tts_active_${groupId}_${device.device_id}`;\n if (flow.get(ttsKey)) {\n flow.set(ttsKey, false);\n stoppedCount++;\n }\n}\n\nnode.status({fill:'green', shape:'dot', text: `✅ ${group.name}: ${stoppedCount} TTS gestoppt`});\nnode.log('Gruppe ' + group.name + ': ' + stoppedCount + ' TTS-Loops gestoppt');\n\nreturn msg;", + "outputs": 1, + "timeout": "", + "noerr": 0, + "initialize": "", + "finalize": "", + "libs": [], + "x": 490, + "y": 1220, + "wires": [[]] + }, { "id": "event_stop_all", "type": "server-events", @@ -762,23 +838,23 @@ {"property": "payload", "propertyType": "msg", "value": "", "valueType": "eventData"} ], "x": 180, - "y": 1300, - "wires": [["func_stop_all_tts"]] + "y": 1280, + "wires": [["func_stop_all_groups_tts"]] }, { - "id": "func_stop_all_tts", + "id": "func_stop_all_groups_tts", "type": "function", "z": "smoke_flow_tab", - "name": "⏹️ ALLE TTS stoppen", - "func": "const config = global.get('smoke_config');\n\nif (!config || !config.devices) {\n node.warn('Keine Config vorhanden!');\n return null;\n}\n\nlet stoppedCount = 0;\nfor (let device of config.devices) {\n const ttsKey = `tts_active_${device.device_id}`;\n if (flow.get(ttsKey)) {\n flow.set(ttsKey, false);\n stoppedCount++;\n }\n}\n\nnode.status({fill:'green', shape:'dot', text: `✅ ${stoppedCount} TTS gestoppt`});\nnode.log('NOTFALL: Alle TTS-Loops gestoppt (' + stoppedCount + ' Geräte)');\n\nreturn msg;", + "name": "⏹️ ALLE TTS stoppen (alle Gruppen)", + "func": "// =====================================================\n// STOPPE ALLE TTS-LOOPS IN ALLEN GRUPPEN\n// =====================================================\n\nconst config = global.get('smoke_config');\n\nif (!config || !config.groups) {\n node.warn('Keine Config vorhanden!');\n return null;\n}\n\nlet stoppedCount = 0;\nfor (let group of config.groups) {\n for (let device of group.devices) {\n const ttsKey = `tts_active_${group.id}_${device.device_id}`;\n if (flow.get(ttsKey)) {\n flow.set(ttsKey, false);\n stoppedCount++;\n }\n }\n}\n\nnode.status({fill:'green', shape:'dot', text: `✅ ${stoppedCount} TTS gestoppt (alle Gruppen)`});\nnode.log('NOTFALL: ' + stoppedCount + ' TTS-Loops in allen Gruppen gestoppt');\n\nreturn msg;", "outputs": 1, "timeout": "", "noerr": 0, "initialize": "", "finalize": "", "libs": [], - "x": 420, - "y": 1300, + "x": 520, + "y": 1280, "wires": [[]] } ] diff --git a/node-red/settings.html b/node-red/settings.html index 7d61c74..66cb01e 100644 --- a/node-red/settings.html +++ b/node-red/settings.html @@ -84,6 +84,120 @@ .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; @@ -251,6 +365,30 @@ 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; @@ -298,46 +436,6 @@ 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; @@ -356,79 +454,7 @@ 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 */ + /* Notfall-Buttons pro Gruppe */ .emergency-section { background: linear-gradient(135deg, #2a1a1a 0%, #1a1a2e 100%); border-color: #5c3a3a; @@ -471,6 +497,78 @@ 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); + } + + .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; @@ -481,6 +579,14 @@ .emergency-buttons { flex-direction: column; } + .group-header { + flex-direction: column; + gap: 15px; + } + .group-header-right { + width: 100%; + justify-content: flex-end; + } } @@ -495,93 +601,26 @@
- -
-
📝 Benachrichtigungen
+ +
-
- - -
- Platzhalter: {detector} wird durch den Rauchmelder-Namen ersetzt -
-
- -
- -
- -
10s
-
-
Wie oft die Sprachausgabe wiederholt wird
-
- -
- - -
-
- - -
-
- 🔥 Rauchmelder - -
- -
- - -
- - -
-
📱 Benachrichtigungs-Geräte
- -
- - -
- - -
-
🚨 Notfall-Aktionen
-
- - -
-
+ +