Geräteprofil · Thermostate
FRITZ!DECT Heizkörperthermostate
Die Heizkörperregler hängen an der FRITZ!Box, nicht am Netzwerk. nomos liest sie deshalb über einen Node-RED-Flow, der sich an der FRITZ!Box anmeldet, die Geräteliste abfragt und jeden Regler auf eigenen MQTT-Topics veröffentlicht — und Sollwert, Ein/Aus und Boost in Gegenrichtung weiterreicht. Der Flow steht weiter unten zum Einfügen bereit.
Ein Flow bedient alle Regler an der Box. In nomos wird je Regler ein Gerät angelegt.
Voraussetzungen
- Ein MQTT-Broker, in nomos unter Skills → MQTT eingetragen.
- Node-RED 1.3 oder neuer mit Zugriff auf denselben Broker. Der Flow braucht nur Standardknoten; einer davon bindet das eingebaute Krypto-Modul ein, das gibt es erst ab dieser Version.
- Ein FRITZ!Box-Benutzer mit Smart-Home-Berechtigung. In der Benutzeroberfläche der FRITZ!Box unter System → FRITZ!Box-Benutzer anlegen, Kennwort vergeben und die Berechtigung Smart-Home-Geräte setzen. Legen Sie dafür einen eigenen Benutzer an, statt das Kennwort des Hauptzugangs zu hinterlegen.
Den Flow einfügen
In Node-RED: Menü rechts oben (☰) → Import → den JSON-Text einfügen → Import → Deploy.
JSON zum Einfügen
[{"id":"nomos_fritzdect","type":"tab","label":"FRITZ!DECT Thermostate","disabled":false,"info":""},{"id":"nomos_fritzdect_broker","type":"mqtt-broker","name":"nomos Broker","broker":"localhost","port":"1883","clientid":"","autoConnect":true,"usetls":false,"protocolVersion":"4","keepalive":"60","cleansession":true,"birthTopic":"","birthQos":"0","birthPayload":"","birthMsg":{},"closeTopic":"","closeQos":"0","closePayload":"","closeMsg":{},"willTopic":"","willQos":"0","willPayload":"","willMsg":{},"userProps":"","sessionExpiry":""},{"id":"nomos_fritzdect_tick","type":"inject","z":"nomos_fritzdect","name":"alle 60 Sekunden","x":150,"y":120,"wires":[["nomos_fritzdect_settings"]],"props":[{"p":"payload"}],"repeat":"60","crontab":"","once":true,"onceDelay":"5","topic":"","payload":"","payloadType":"date"},{"id":"nomos_fritzdect_settings","type":"function","z":"nomos_fritzdect","name":"Zugangsdaten","x":340,"y":120,"wires":[["nomos_fritzdect_session"]],"func":"// Hier eintragen: Adresse der FRITZ!Box sowie Benutzername und Kennwort eines\n// Benutzers mit Smart-Home-Berechtigung. Sonst ist nichts zu aendern.\nflow.set(\"fritzUrl\", \"http://fritz.box\");\nflow.set(\"fritzUser\", \"smarthome\");\nflow.set(\"fritzPassword\", \"KENNWORT-HIER\");\n// Topic-Wurzel; das Geraet in nomos bekommt spaeter fritzdect/<AIN>\nflow.set(\"fritzRoot\", \"fritzdect\");\nreturn msg;\n","outputs":1,"timeout":0,"noerr":0,"initialize":"","finalize":"","libs":[]},{"id":"nomos_fritzdect_session","type":"function","z":"nomos_fritzdect","name":"Sitzung vorhanden?","x":540,"y":120,"wires":[["nomos_fritzdect_login1"],["nomos_fritzdect_listurl"]],"func":"// Eine Sitzung gilt 20 Minuten und verlaengert sich bei Zugriff. Nur wenn keine\n// vorliegt, wird angemeldet; sonst geht es direkt zum Lesen der Geraeteliste.\nconst sid = flow.get(\"fritzSid\");\nif (sid) { return [null, msg]; }\nmsg.url = flow.get(\"fritzUrl\") + \"/login_sid.lua?version=2\";\nreturn [msg, null];\n","outputs":2,"timeout":0,"noerr":0,"initialize":"","finalize":"","libs":[]},{"id":"nomos_fritzdect_login1","type":"http request","z":"nomos_fritzdect","name":"Anmeldung starten","x":740,"y":80,"wires":[["nomos_fritzdect_xml1"]],"method":"GET","ret":"txt","paytoqs":"ignore","url":"","tls":"","persist":false,"proxy":"","insecureHTTPParser":false,"authType":"","senderr":false,"headers":[]},{"id":"nomos_fritzdect_xml1","type":"xml","z":"nomos_fritzdect","name":"XML","x":900,"y":80,"wires":[["nomos_fritzdect_challenge"]],"property":"payload","attr":"$","chr":"_"},{"id":"nomos_fritzdect_challenge","type":"function","z":"nomos_fritzdect","name":"Antwort berechnen","x":1060,"y":80,"wires":[["nomos_fritzdect_login2"]],"func":"// Antwort auf die Challenge bilden. Beide Verfahren stehen in der Technical Note\n// von AVM: PBKDF2 ab FRITZ!OS 7.24 (Challenge beginnt mit \"2$\"), davor MD5.\nconst info = msg.payload && msg.payload.SessionInfo;\nif (!info) { node.warn(\"keine Anmeldeseite erhalten\"); return null; }\nconst first = (v) => (Array.isArray(v) ? v[0] : v);\nconst challenge = first(info.Challenge);\nconst blocktime = parseInt(first(info.BlockTime) || \"0\", 10);\nconst password = flow.get(\"fritzPassword\") || \"\";\nif (blocktime > 0) {\n node.warn(\"Die FRITZ!Box sperrt die Anmeldung noch \" + blocktime + \" Sekunden\");\n return null;\n}\n\nlet response;\nif (String(challenge).startsWith(\"2$\")) {\n const p = String(challenge).split(\"$\");\n const hash1 = crypto.pbkdf2Sync(Buffer.from(password, \"utf8\"),\n Buffer.from(p[2], \"hex\"), parseInt(p[1], 10), 32, \"sha256\");\n const hash2 = crypto.pbkdf2Sync(hash1, Buffer.from(p[4], \"hex\"), parseInt(p[3], 10), 32, \"sha256\");\n response = p[4] + \"$\" + hash2.toString(\"hex\");\n} else {\n // Aeltere FRITZ!OS-Versionen: MD5 ueber UTF-16LE; Zeichen oberhalb von 255\n // werden laut Dokument durch einen Punkt ersetzt\n const text = String(challenge) + \"-\" + password;\n const safe = Array.from(text).map((c) => (c.codePointAt(0) > 255 ? \".\" : c)).join(\"\");\n const md5 = crypto.createHash(\"md5\").update(Buffer.from(safe, \"utf16le\")).digest(\"hex\");\n response = challenge + \"-\" + md5;\n}\n\nmsg.url = flow.get(\"fritzUrl\") + \"/login_sid.lua?version=2\";\nmsg.headers = { \"content-type\": \"application/x-www-form-urlencoded\" };\nmsg.payload = { username: flow.get(\"fritzUser\") || \"\", response: response };\nreturn msg;\n","outputs":1,"timeout":0,"noerr":0,"initialize":"","finalize":"","libs":[{"var":"crypto","module":"crypto"}]},{"id":"nomos_fritzdect_login2","type":"http request","z":"nomos_fritzdect","name":"Anmelden","x":1250,"y":80,"wires":[["nomos_fritzdect_xml2"]],"method":"POST","ret":"txt","paytoqs":"ignore","url":"","tls":"","persist":false,"proxy":"","insecureHTTPParser":false,"authType":"","senderr":false,"headers":[]},{"id":"nomos_fritzdect_xml2","type":"xml","z":"nomos_fritzdect","name":"XML","x":1400,"y":80,"wires":[["nomos_fritzdect_storesid"]],"property":"payload","attr":"$","chr":"_"},{"id":"nomos_fritzdect_storesid","type":"function","z":"nomos_fritzdect","name":"Sitzung merken","x":1550,"y":80,"wires":[["nomos_fritzdect_listurl"]],"func":"// Die Antwort enthaelt die Sitzungs-ID. Nur Nullen heisst: Benutzer oder Kennwort falsch.\nconst info = msg.payload && msg.payload.SessionInfo;\nconst first = (v) => (Array.isArray(v) ? v[0] : v);\nconst sid = info ? String(first(info.SID) || \"\") : \"\";\nif (!sid || /^0+$/.test(sid)) {\n node.warn(\"Anmeldung abgelehnt - Benutzername, Kennwort oder Smart-Home-Berechtigung pruefen\");\n flow.set(\"fritzSid\", null);\n return null;\n}\nflow.set(\"fritzSid\", sid);\nreturn msg;\n","outputs":1,"timeout":0,"noerr":0,"initialize":"","finalize":"","libs":[]},{"id":"nomos_fritzdect_listurl","type":"function","z":"nomos_fritzdect","name":"Geraeteliste anfordern","x":540,"y":220,"wires":[["nomos_fritzdect_listreq"]],"func":"// Geraeteliste anfordern\nconst sid = flow.get(\"fritzSid\");\nif (!sid) { return null; }\nmsg.method = \"GET\";\nmsg.headers = {};\nmsg.payload = \"\";\nmsg.url = flow.get(\"fritzUrl\") + \"/webservices/homeautoswitch.lua\"\n + \"?switchcmd=getdevicelistinfos&sid=\" + sid;\nreturn msg;\n","outputs":1,"timeout":0,"noerr":0,"initialize":"","finalize":"","libs":[]},{"id":"nomos_fritzdect_listreq","type":"http request","z":"nomos_fritzdect","name":"Geraete lesen","x":760,"y":220,"wires":[["nomos_fritzdect_xml3"]],"method":"GET","ret":"txt","paytoqs":"ignore","url":"","tls":"","persist":false,"proxy":"","insecureHTTPParser":false,"authType":"","senderr":false,"headers":[]},{"id":"nomos_fritzdect_xml3","type":"xml","z":"nomos_fritzdect","name":"XML","x":920,"y":220,"wires":[["nomos_fritzdect_publish"]],"property":"payload","attr":"$","chr":"_"},{"id":"nomos_fritzdect_publish","type":"function","z":"nomos_fritzdect","name":"Thermostate veroeffentlichen","x":1120,"y":220,"wires":[["nomos_fritzdect_out"]],"func":"// Aus der Geraeteliste je Heizkoerperregler einen Satz Topics bauen.\n// Werte und Einheiten nach AHA-HTTP-Interface: tist/tsoll in 0,5 Grad,\n// 16..56 = 8..28 Grad, 253 = Aus, 254 = An.\nconst root = flow.get(\"fritzRoot\") || \"fritzdect\";\nconst list = msg.payload && msg.payload.devicelist;\nif (!list) {\n // Keine Geraeteliste: meist eine abgelaufene Sitzung\n node.warn(\"keine Geraeteliste erhalten - Sitzung wird erneuert\");\n flow.set(\"fritzSid\", null);\n return null;\n}\n\nconst first = (v) => (Array.isArray(v) ? v[0] : v);\nconst num = (v) => {\n const t = first(v);\n if (t === undefined || t === null || t === \"\") return null;\n const n = Number(t);\n return isNaN(n) ? null : n;\n};\nconst flag = (v) => (num(v) === 1);\nconst temp = (v) => {\n const n = num(v);\n if (n === null || n === 253 || n === 254) return null;\n return n / 2;\n};\n\nconst FAULTS = {\n 1: \"Keine Adaptierung moeglich - sitzt der Regler richtig am Heizkoerper?\",\n 2: \"Ventilhub zu kurz oder Batterieleistung zu schwach\",\n 3: \"Keine Ventilbewegung moeglich - Ventilstoessel frei?\",\n 4: \"Die Installation wird vorbereitet\",\n 5: \"Installationsmodus - der Regler kann montiert werden\",\n 6: \"Der Regler passt sich an den Hub des Ventils an\"\n};\n\nconst devices = [].concat(list.device || []);\nconst out = [];\nconst known = {};\n\ndevices.forEach((dev) => {\n const hkr = first(dev.hkr);\n if (!hkr) return; // kein Heizkoerperregler\n const identifier = dev.$ && dev.$.identifier;\n if (!identifier) return;\n const topic = root + \"/\" + String(identifier).replace(/\\s/g, \"\");\n known[topic] = String(identifier);\n\n const present = flag(dev.present);\n const tsollRaw = num(hkr.tsoll);\n const on = tsollRaw !== 253;\n const window = flag(hkr.windowopenactiv);\n const boost = flag(hkr.boostactive);\n const holiday = flag(hkr.holidayactive);\n const summer = flag(hkr.summeractive);\n const code = num(hkr.errorcode) || 0;\n const low = flag(hkr.batterylow);\n\n let status = \"Heizen\";\n if (!present) status = \"Keine Verbindung\";\n else if (code) status = \"Stoerung\";\n else if (window) status = \"Fenster offen\";\n else if (boost) status = \"Boost\";\n else if (holiday) status = \"Urlaub\";\n else if (summer) status = \"Sommerbetrieb\";\n else if (!on) status = \"Aus\";\n\n const values = {\n TEMPERATURE: temp(hkr.tist),\n SETPOINT: temp(hkr.tsoll),\n COMFORT: temp(hkr.komfort),\n ECO: temp(hkr.absenk),\n POWER: on ? 1 : 0,\n STATUS: status,\n BATTERY: num(dev.battery),\n BATTERYLOW: low ? 1 : 0,\n WINDOW: window ? 1 : 0,\n BOOST: boost ? 1 : 0,\n HOLIDAY: holiday ? 1 : 0,\n SUMMER: summer ? 1 : 0,\n ADAPTIVE: flag(hkr.adaptiveHeatingRunning) ? 1 : 0,\n MALFUNCTION: (code || low) ? 1 : 0,\n ERROR: code ? (FAULTS[code] || (\"Fehlercode \" + code)) : \"\",\n PRESENT: present ? 1 : 0\n };\n\n Object.keys(values).forEach((key) => {\n if (values[key] === null) return; // unbekannt: nichts senden\n out.push({ topic: \"stat/\" + topic + \"/\" + key, payload: values[key], retain: true });\n });\n out.push({ topic: \"tele/\" + topic + \"/LWT\", payload: present ? \"Online\" : \"Offline\",\n retain: true });\n});\n\n// Zuordnung Topic -> AIN merken, der Befehlszweig braucht sie\nflow.set(\"fritzDevices\", known);\nif (!out.length) { node.warn(\"kein Heizkoerperregler in der Geraeteliste\"); return null; }\nreturn [out];\n","outputs":1,"timeout":0,"noerr":0,"initialize":"","finalize":"","libs":[]},{"id":"nomos_fritzdect_out","type":"mqtt out","z":"nomos_fritzdect","name":"an nomos","x":1360,"y":220,"wires":[],"topic":"","qos":"0","retain":"true","respTopic":"","contentType":"","userProps":"","correl":"","expiry":"","broker":"nomos_fritzdect_broker"},{"id":"nomos_fritzdect_in","type":"mqtt in","z":"nomos_fritzdect","name":"Befehle aus nomos","x":150,"y":340,"wires":[["nomos_fritzdect_command"]],"topic":"cmnd/fritzdect/+/+","qos":"0","datatype":"auto-detect","broker":"nomos_fritzdect_broker","nl":false,"rap":true,"rh":0,"inputs":0},{"id":"nomos_fritzdect_command","type":"function","z":"nomos_fritzdect","name":"Befehl umsetzen","x":390,"y":340,"wires":[["nomos_fritzdect_cmdreq"]],"func":"// Befehl aus nomos in einen AHA-Aufruf uebersetzen.\n// Topic: cmnd/<Wurzel>/<AIN>/<BEFEHL>\nconst sid = flow.get(\"fritzSid\");\nif (!sid) { node.warn(\"noch keine Sitzung - Befehl verworfen\"); return null; }\n\nconst parts = String(msg.topic).split(\"/\");\nconst key = parts.slice(1, parts.length - 1).join(\"/\");\nconst command = parts[parts.length - 1];\nconst known = flow.get(\"fritzDevices\") || {};\nconst ain = known[key];\nif (!ain) { node.warn(\"unbekanntes Geraet im Topic: \" + msg.topic); return null; }\n\nconst value = String(msg.payload).trim();\nconst on = value === \"1\" || value.toLowerCase() === \"on\" || value.toLowerCase() === \"true\";\nlet query;\n\nif (command === \"SETPOINT\") {\n const c = Number(value);\n if (isNaN(c)) { node.warn(\"keine Zahl: \" + value); return null; }\n // 0,5-Grad-Schritte, 16..56 entspricht 8..28 Grad\n let p = Math.round(c * 2);\n if (p < 16) p = 16;\n if (p > 56) p = 56;\n query = \"switchcmd=sethkrtsoll¶m=\" + p;\n} else if (command === \"POWER\") {\n query = \"switchcmd=sethkrtsoll¶m=\" + (on ? 254 : 253);\n} else if (command === \"BOOST\") {\n // Boost laeuft hoechstens 24 Stunden; eine Stunde ist das uebliche Mass\n const end = on ? Math.floor(Date.now() / 1000) + 3600 : 0;\n query = \"switchcmd=sethkrboost&endtimestamp=\" + end;\n} else {\n node.warn(\"unbekannter Befehl: \" + command);\n return null;\n}\n\nmsg.method = \"GET\";\nmsg.headers = {};\nmsg.payload = \"\";\nmsg.url = flow.get(\"fritzUrl\") + \"/webservices/homeautoswitch.lua?\"\n + query + \"&ain=\" + encodeURIComponent(ain) + \"&sid=\" + sid;\nreturn msg;\n","outputs":1,"timeout":0,"noerr":0,"initialize":"","finalize":"","libs":[]},{"id":"nomos_fritzdect_cmdreq","type":"http request","z":"nomos_fritzdect","name":"Befehl senden","x":610,"y":340,"wires":[["nomos_fritzdect_listurl"]],"method":"GET","ret":"txt","paytoqs":"ignore","url":"","tls":"","persist":false,"proxy":"","insecureHTTPParser":false,"authType":"","senderr":false,"headers":[]}]
Danach zwei Knoten anpassen:
- Zugangsdaten — Adresse der FRITZ!Box sowie Benutzername und Kennwort des eben angelegten Benutzers. Hier steht auch die Topic-Wurzel
fritzdect, die normalerweise so bleibt. - an nomos und Befehle aus nomos — den Broker eintragen, Adresse und bei Bedarf Zugangsdaten.
Nach dem Deploy erscheinen die Regler binnen einer Minute auf dem Broker.
Das Gerät in nomos anlegen
- Skills → Add-ons: das Profil importieren
- Je Heizkörperregler ein Gerät anlegen und als MQTT-Topic
fritzdect/<AIN>eintragen — die AIN ohne Leerzeichen
Die AIN steht auf dem Typenschild des Reglers und in der FRITZ!Box unter Smart Home. Aus der AIN 13077 0000258 wird damit das Topic:
fritzdect/130770000258
Was der Flow veröffentlicht
| Topic | Inhalt |
|---|---|
stat/<Topic>/TEMPERATURE |
gemessene Temperatur in °C |
stat/<Topic>/SETPOINT |
Solltemperatur in °C |
stat/<Topic>/POWER |
1, solange der Regler nicht auf Aus steht |
stat/<Topic>/STATUS |
Heizen, Aus, Fenster offen, Boost, Urlaub, Sommerbetrieb, Störung oder Keine Verbindung |
stat/<Topic>/BATTERY |
Batterie in Prozent |
stat/<Topic>/MALFUNCTION |
1 bei Störung oder schwacher Batterie |
stat/<Topic>/WINDOW |
Fenster-offen-Erkennung aktiv |
stat/<Topic>/BOOST |
Boost läuft |
stat/<Topic>/COMFORT, ECO |
Komfort- und Absenktemperatur des Zeitprogramms |
stat/<Topic>/HOLIDAY, SUMMER, ADAPTIVE, BATTERYLOW, PRESENT, ERROR |
ausgeblendete Zusatzwerte |
tele/<Topic>/LWT |
Online, solange der Regler Funkkontakt zur FRITZ!Box hat |
Alles retained, damit nach einem Neustart sofort wieder Werte dastehen.
In Gegenrichtung hört der Flow auf cmnd/fritzdect/<AIN>/SETPOINT, /POWER und /BOOST.
Datenbereich
- Temperatur, Solltemperatur, Status und Batterie
- Fenster offen und Boost
- Störung (
malfunction) samt Klartext zur Ursache, ausgeblendet - Komfort- und Absenktemperatur, Urlaubs- und Sommerzeitraum, adaptive Heizregelung und die Funkverbindung des Reglers — ausgeblendet
- Erreichbar (
reachable) aus dem Funkkontakt des Reglers
Schaltbar sind die Solltemperatur (8 bis 28 °C in halben Grad), Ein/Aus und Boost.
Hinweise
- Ein Sollwert wirkt nicht sofort. Die Regler sind Funkgeräte mit Batterie und melden sich nur in Abständen bei der FRITZ!Box. Ein geschriebener Wert wird vorgemerkt und erreicht den Regler beim nächsten Funkkontakt; bis dahin zeigt nomos weiter den alten Wert. Das ist keine Störung des Flows.
- Der Flow fragt im Minutentakt ab. Häufiger bringt nichts: die Werte ändern sich langsam, und jede Abfrage belastet die FRITZ!Box.
- Die Anmeldung gilt 20 Minuten und verlängert sich bei Zugriff. Der Flow meldet sich deshalb nur an, wenn er keine gültige Sitzung hat — die Zahl gleichzeitiger Sitzungen an einer FRITZ!Box ist begrenzt.
- Falsches Kennwort sperrt. Nach Fehlversuchen verweigert die FRITZ!Box die Anmeldung für eine wachsende Zeitspanne. Der Flow erkennt das, wartet ab und schreibt den Grund in den Debug-Bereich von Node-RED.
- Aus heißt zu.
Ausfährt das Ventil zu,Einöffnet es dauerhaft; dazwischen regelt der Regler auf die Solltemperatur. SolangeAusoderEingesetzt ist, liefert der Regler keine Solltemperatur, und die Zeile in nomos behält ihren letzten Wert. - Aktiv bedeutet: der Regler steht nicht auf Aus. Ob das Ventil gerade öffnet, meldet die Schnittstelle nicht.
- Zeitprogramm, Komfort- und Absenktemperatur werden nur gelesen. Geändert werden sie in der FRITZ!Box; der Flow schreibt nur Sollwert, Ein/Aus und Boost.
- Boost läuft eine Stunde und endet dann von selbst. Wer das anders will, ändert die Sekundenzahl im Knoten Befehl umsetzen.
- Läuft Node-RED nicht, merkt nomos das nicht sofort. Das Lebenszeichen beschreibt den Funkkontakt zwischen Regler und FRITZ!Box; stoppt der Flow, bleibt der zuletzt gesendete Stand stehen.
- Nur Heizkörperregler. Steckdosen, Taster, Lampen und Rollläden an derselben FRITZ!Box übergeht der Flow.
- Die genutzte Schnittstelle wird vom Hersteller nicht mehr weiterentwickelt, bleibt aber in FRITZ!OS enthalten. Sollte sie eines Tages wegfallen, muss der Flow auf die neuere Schnittstelle umgestellt werden — das Profil und die Topics bleiben davon unberührt.