-
-
Save Argelbargel/bfbcbc09e90bf2bac3d9a3ccaeb2506b to your computer and use it in GitHub Desktop.
// ************************************************************************************************************************************* | |
// Groovy Script/Rule for OpenHAB to update the temperature offset of AVM Fritz! DECT 301/302 or COMET DECT Thermostats based on | |
// measurements of arbitrary external temperature-sensor connected as items/things in OpenHAB. | |
// This implementation is based on https://github.com/SeydX/homebridge-fritz-platform/issues/149#issuecomment-883098365 an uses the | |
// login-example code provided by AVM here: https://avm.de/fileadmin/user_upload/Global/Service/Schnittstellen/AVM_Technical_Note_-_Session_ID_english_2021-05-03.pdf | |
// | |
// Requirements: | |
// - Fritzbox with FritzOS-Version < 7.50 | |
// - install the XPATH-Transformation-Addon | |
// - download Google's GSON-Library (e.g. from https://mavenlibs.com/jar/file/com.google.code.gson/gson) and place it in OpenHABs | |
// classpath (e.g. /usr/share/openhab/runtime/lib) | |
// ************************************************************************************************************************************* | |
import org.openhab.core.model.script.actions.HTTP | |
import org.openhab.core.model.script.actions.Log | |
import org.openhab.core.transform.actions.Transformation | |
import java.nio.charset.StandardCharsets | |
import java.security.InvalidKeyException | |
import java.security.NoSuchAlgorithmException | |
import javax.crypto.Mac | |
import javax.crypto.spec.SecretKeySpec | |
import com.google.gson.Gson | |
import java.util.Map | |
import org.slf4j.LoggerFactory | |
def HOST = 'fritz.box' // Fritzbox-Host | |
def USER = '<user>' // Fritzbox-User | |
def PASSWORD = '<password>' // Fritzbox-Password | |
// Sensor-Mapping: Thermostat-ULE-Device-Name : List of Temperature-Sensors (QuantityType) | |
def SENSORS = [ | |
'<ulename>' : ['<item_id1>', '<item_id2>'], | |
// ... | |
] | |
def LOG = LoggerFactory.getLogger("update-temperature-offset") | |
class FritzboxLoginState { | |
private String host | |
private String user | |
private String sid | |
private String challenge | |
private String blockTime | |
FritzboxLoginState(def host, def user = null, def sid, def challenge = null, def blockTime = 0) { | |
this.host = host | |
this.sid = sid | |
this.user = user | |
this.challenge = challenge | |
this.blockTime = blockTime | |
} | |
String getHost() { | |
return host | |
} | |
String getUser() { | |
return user | |
} | |
String getSid() { | |
return sid | |
} | |
int getBlockTime() { | |
return blockTime | |
} | |
String getChallenge() { | |
return challenge | |
} | |
boolean isLoggedIn() { | |
return sid != "0000000000000000" | |
} | |
void logout() { | |
HTTP.sendHttpGetRequest("http://${host}/login_sid.lua?logout=1&sid=${sid}", 10000) | |
sid = "0000000000000000" | |
} | |
public String toString() { | |
return getClass().getSimpleName() + "[host=${host}, user=${user}, sid=${sid}, challenge=${challenge}, blockTime=${blockTime}]" | |
} | |
} | |
class FritzboxLogin { | |
static LOG = LoggerFactory.getLogger("update-temperature-offset-fritzbox-login") | |
private String host | |
private String user | |
private String password | |
private FritzboxLoginState stateCache | |
FritzboxLogin(def user, def password, def host = "fritz.box") { | |
this.host = host | |
this.user = user | |
this.password = password | |
} | |
public getState() { | |
if (!stateCache) { | |
def resp = HTTP.sendHttpGetRequest("http://${host}/login_sid.lua?version=2&user=${user}", 10000) | |
def user = Transformation.transform("XPATH", "/SessionInfo/Users/User[@last=1]/text()", resp) | |
def challenge = Transformation.transform("XPATH", "/SessionInfo/Challenge/text()", resp) | |
def blockTime = Integer.parseInt(Transformation.transform("XPATH", "/SessionInfo/BlockTime/text()", resp)) | |
stateCache = (user == this.user) | |
? new FritzboxLoginState(host, user, Transformation.transform("XPATH", "/SessionInfo/SID/text()", resp), challenge, blockTime) | |
: new FritzboxLoginState(host, this.user, "0000000000000000", challenge, blockTime) | |
} | |
return stateCache | |
} | |
public FritzboxLoginState execute() { | |
return state.loggedIn ? state : login() | |
} | |
private FritzboxLoginState login() { | |
def challengeResponse = calculatePbkdf2Response(state.challenge, password) | |
def resp = HTTP.sendHttpPostRequest("http://${host}/login_sid.lua?version=2&user=${user}", "application/x-www-form-urlencoded", "response=${challengeResponse}", 10000) | |
def sid = Transformation.transform("XPATH", "/SessionInfo/SID/text()", resp) | |
if (sid == "0000000000000000") { | |
throw new IllegalStateException("invalid user/password!") | |
} | |
stateCache = new FritzboxLoginState(host, user, sid) | |
return stateCache | |
} | |
/** | |
* Calculate the secret key on Android. | |
*/ | |
private String calculatePbkdf2Response(String challenge, String password) { | |
String[] challenge_parts = challenge.split('\\$'); | |
int iter1 = Integer.parseInt(challenge_parts[1]); | |
byte[] salt1 = fromHex(challenge_parts[2]); | |
int iter2 = Integer.parseInt(challenge_parts[3]); | |
byte[] salt2 = fromHex(challenge_parts[4]); | |
byte[] hash1 = pbkdf2HmacSha256(password.getBytes(StandardCharsets.UTF_8), salt1, iter1); | |
byte[] hash2 = pbkdf2HmacSha256(hash1, salt2, iter2); | |
return challenge_parts[4] + '$' + toHex(hash2); | |
} | |
/** Hex string to bytes */ | |
private byte[] fromHex(String hexString) { | |
int len = hexString.length() / 2; | |
byte[] ret = new byte[len]; | |
for (int i = 0; i < len; i++) { | |
ret[i] = (byte) Short.parseShort(hexString.substring(i * 2, i * 2 + 2), 16); | |
} | |
return ret; | |
} | |
/** byte array to hex string */ | |
private String toHex(byte[] bytes) { | |
StringBuilder s = new StringBuilder(bytes.length * 2); | |
for (byte b : bytes) { s.append(String.format("%02x", b)); } | |
return s.toString(); | |
} | |
/** | |
* Create a pbkdf2 HMAC by appling the Hmac iter times as specified. | |
* We can't use the Android-internal PBKDF2 here, as it only accepts char[] arrays, not bytes (for multi-stage hashing) | |
*/ | |
private byte[] pbkdf2HmacSha256(final byte[] password, final byte[] salt, int iters) { | |
try { | |
String alg = "HmacSHA256"; | |
Mac sha256mac = Mac.getInstance(alg); | |
sha256mac.init(new SecretKeySpec(password, alg)); | |
byte[] ret = new byte[sha256mac.getMacLength()]; | |
byte[] tmp = new byte[salt.length + 4]; | |
System.arraycopy(salt, 0, tmp, 0, salt.length); | |
tmp[salt.length + 3] = 1; | |
for (int i = 0; i < iters; i++) { | |
tmp = sha256mac.doFinal(tmp); | |
for (int k = 0; k < ret.length; k++) { ret[k] ^= tmp[k]; } | |
} | |
return ret; | |
} catch (NoSuchAlgorithmException | InvalidKeyException e) { | |
return null; // TODO: Handle this properly | |
} | |
} | |
} | |
class FritzboxThermostat { | |
def id | |
def displayName | |
def offset | |
def correctedCelsius | |
def doNotHeatOffsetInMinutes | |
def FritzboxThermostat(def id, def displayName, def offset, def correctedCelsius, doNotHeatOffsetInMinutes) { | |
this.id = id | |
this.displayName = displayName | |
this.offset = offset | |
this.correctedCelsius = correctedCelsius | |
this.doNotHeatOffsetInMinutes = doNotHeatOffsetInMinutes | |
} | |
def getId() { | |
return id | |
} | |
def getUleDeviceName() { | |
return displayName | |
} | |
def getMeasuredTemp() { | |
return correctedCelsius - offset | |
} | |
def getOffset() { | |
return offset | |
} | |
def getRoomTemp() { | |
return correctedCelsius | |
} | |
def getWindowOpenTimer() { | |
return doNotHeatOffsetInMinutes | |
} | |
def updateOffset(def newOffset) { | |
return new FritzboxThermostat(id, displayName, newOffset, correctedCelsius - (offset - newOffset), doNotHeatOffsetInMinutes) | |
} | |
public String toString() { | |
return getClass().getSimpleName() + "[uleDeviceName=${displayName}, id=${id}, offset=${offset}, measuredTemp=${measuredTemp}, roomTemp=${roomTemp}, windowOpenTimer=${doNotHeatOffsetInMinutes}]" | |
} | |
} | |
def getThermostats(def loginState) { | |
def resp = HTTP.sendHttpPostRequest("http://${loginState.host}/data.lua?sid=${loginState.sid}", "application/x-www-form-urlencoded", "?xhr=1&lang=de&page=sh_dev&xhrId=all&no_sidrenew=", 10000) | |
def devices = new Gson().fromJson(resp, Map.class) | |
return devices['data']['devices'] | |
.findAll { it.category == "THERMOSTAT" && it.units.find { unit -> unit.type == "TEMPERATURE_SENSOR" && unit.skills.find { skill -> skill['offset'] != null } } } | |
.collect { | |
def temperatureSensor = it.units.find { unit -> unit.type == "TEMPERATURE_SENSOR" }.skills.find { skill -> skill['offset'] != null } | |
def windowOpenSensor = it.units.find { unit -> unit.type == "THERMOSTAT" }.skills.find { skill -> skill['temperatureDropDetection'] != null }.temperatureDropDetection | |
return new FritzboxThermostat((int) it.id, it.displayName, temperatureSensor.offset, temperatureSensor.currentInCelsius, (int) windowOpenSensor.doNotHeatOffsetInMinutes) | |
} | |
.collectEntries { [it.displayName, it] } | |
} | |
def temperatures = SENSORS.collectEntries { name, sensors -> | |
[name, (sensors.collect { sensor -> items.get(sensor).floatValue() }.inject(0) { acc, curr -> acc += curr } / sensors.size).round(1)] | |
} | |
def fritzboxLoginState = new FritzboxLogin(USER, PASSWORD, HOST).execute() | |
def thermostats = getThermostats(fritzboxLoginState) | |
def offsets = temperatures.collectEntries { name, temperature -> | |
return [name, (temperature - thermostats[name].measuredTemp).round(2)] | |
} | |
def roundedOffsets = offsets.collectEntries { name, offset -> | |
return [name, (offset * 2).round() / 2f ] | |
} | |
LOG.info("measured average temperatures: " + temperatures) | |
LOG.info("thermostat settings: " + thermostats) | |
LOG.debug(offsets as String) | |
LOG.debug(roundedOffsets as String) | |
def thermostatsToUpdate = thermostats | |
.findAll { roundedOffsets[it.key] != it.value.offset } | |
.collectEntries { name, thermostat -> [name, thermostat.updateOffset(roundedOffsets[name])] } | |
LOG.info("updating thermostats: " + thermostatsToUpdate as String) | |
thermostatsToUpdate.forEach { name, t -> | |
HTTP.sendHttpPostRequest( | |
"http://${fritzboxLoginState.host}/net/home_auto_hkr_edit.lua", | |
"application/x-www-form-urlencoded", | |
"sid=${fritzboxLoginState.sid}&device=${t.id}&Offset=${t.offset}&Roomtemp=${t.roomTemp}&ule_device_name=${t.uleDeviceName}&WindowOpenTimer=${t.windowOpenTimer}&view=&back_to_page=sh_dev&validate=apply&xhr=1&useajax=1", | |
10000 | |
) | |
HTTP.sendHttpPostRequest( | |
"http://${fritzboxLoginState.host}/data.lua", | |
"application/x-www-form-urlencoded", | |
"sid=${fritzboxLoginState.sid}&device=${t.id}&Offset=${t.offset}&Roomtemp=${t.roomTemp}&ule_device_name=${t.uleDeviceName}&WindowOpenTimer=${t.windowOpenTimer}&WindowOpenTrigger=&tempsensor=own&ExtTempsensorID=tochoose&view=&back_to_page=sh_dev&lang=de&xhr=1&no_sidrenew=&apply=&oldpage=%2Fnet%2Fhome_auto_hkr_edit.lua", | |
10000 | |
) | |
} | |
LOG.info("new thermostat settings: " + getThermostats(fritzboxLoginState)) | |
fritzboxLoginState.logout() | |
Hi @coldbird1996 ,
sorry für die späte Antwort. Zunächst: diese Lösung funktioniert nicht mehr mit den vor kurzen releasten FritzOS-Versionen 7.50+. Da ich eine ältere Fritzbox nutze, die das Update nicht bekommen wird, kann ich leider auch keine Anpassungen dafür machen.
Solltest Du noch eine ältere FritzOS-Version nutzen, dann legst Du in openHAB ein Script mit dem Code hier und eine Regel an, die alle x Minuten (z.B. alle 15min) gestartet wird und dann das Script ausführt.
User&Password sind entweder dein Admin-Zugang ("Admin" und das Passwort der Box) oder besser, Du legst Dir einen eigenen User auf der Box an.
Sensor kann die ID jedes Items sein, dass Dir einen Zahlenwert liefert. Bei meinen Shelly-H&T ist das also das Item für die Temperatur.
Hier findest Du minimal mehr Infos: https://community.openhab.org/t/groovy-script-rule-to-update-temperature-offsets-of-avm-fritz-dect-301-302-based-on-external-temperature-sensors/139917 - und kannst mich über das Forum direkt anschreiben, dann reagiere Ich etwas schneller;-)
Hi! Bin auf dein Skript gestoßen, als ich nach Lösungen für mein Home Assistant setup gesucht habe. Danke für die Arbeit! Dadurch konnte ich ein passendes Python Skript basteln.
Meine Fritzbox hat die Nacht nun auch mal das 7.50 update bekommen. Habe dementsprechend mein Skript angepasst und es funktioniert bisher.
Änderungen, die relevant zu sein scheinen sind:
- Beide requests gehen an
http://fritz.box/data.lua
- Die erste request benutzt folgende url params zusätzlich zu den Thermostat settings:
{'sid': '', 'xhr': 1, 'device': '', 'page': 'home_auto_hkr_edit', 'validate': 'apply', 'useajax': 1, 'view': '', 'back_to_page': '/smarthome/devices.lua', 'apply': '', 'lang': 'en', 'graphState': 1, 'tempsensor': 'own', 'Heiztemp': ....., ... }
- Die zweite request benutzt nur folgende url params:
{'sid': '', 'xhr': 1, 'device': '', 'page': 'home_auto_hkr_edit'}
'startTime'
key unter den "WEEKLY_TIMETABLE" actions ist nicht mehr unter['timeSetting']['time']['startTime']
, sondern['timeSetting']'startTime']
- Es gibt außerdem den neuen parameter
"hkr_adaptheat"
, welcher entweder mit 0 oder 1 übergeben wird umAdaptive heating start enabled
einzustellen. Allerdings soweit ich das verstehe nur relevant, wenn man die Schedule funktion des Thermostats in der Fritzbox verwendet.
Der rest scheint soweit gleich geblieben zu sein. Mein Skript funktioniert auf jedenfall wieder nach den Änderungen auf 7.50.
Gist mit meinem Skript: https://gist.github.com/Daendaralus/87088a8155a93065d2f2e8de2e1548b4
Hi @Daendaralus,
danke für die Anpassungen! Ich schau mir das sicher demnächst mal an, denn meine ältere FritzBox wird das Update wohl doch auch noch bekommen.
Grüße
Hi @coldbird1996,
Hi @Daendaralus,
ich habe endlich mal die Zeit gefunden, um das Script an die neueren FritzOS-Versionen anzupassen. Die neue Version findet ihr hier: https://gist.github.com/Argelbargel/ee2acc2039dd29c4d65d1d1b44ac9143
Hab es noch nicht intensiver getestet, aber diese Variante scheint bei mir die Offsets wieder korrekt anzupassen...
Hallo,
danke für deine Lösung.
Ich bin leider ein Laie was das angeht. Muss ich den Code einfach bei openhab einbinden und dann funktioniert alles?
Wo würde ich die für mich passenden Parameter wie zb user Name und Passwort der Fritz Box? Und wie wähle ich den externen Temperatur Sensor aus der für das Thermostat in frage kommt?
Vielen Dank für deine Mühen, das ist nach langem Suchen endlich mal eine vielversprechende Lösung
Viele Grüße
Christopher