nomos system nomos system

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

  1. Ein MQTT-Broker, in nomos unter Skills → MQTT eingetragen.
  2. 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.
  3. 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&param=\" + p;\n} else if (command === \"POWER\") {\n    query = \"switchcmd=sethkrtsoll&param=\" + (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:

Nach dem Deploy erscheinen die Regler binnen einer Minute auf dem Broker.

Das Gerät in nomos anlegen

  1. Skills → Add-ons: das Profil importieren
  2. 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

Schaltbar sind die Solltemperatur (8 bis 28 °C in halben Grad), Ein/Aus und Boost.

Hinweise