Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Save Argelbargel/bfbcbc09e90bf2bac3d9a3ccaeb2506b to your computer and use it in GitHub Desktop.
Save Argelbargel/bfbcbc09e90bf2bac3d9a3ccaeb2506b to your computer and use it in GitHub Desktop.
OpenHAB-Rule to update temperature offsets of AVM Fritz! DECT Radiator Thermostats
// *************************************************************************************************************************************
// 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:
// - 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()
@coldbird1996
Copy link

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

@Argelbargel
Copy link
Author

Argelbargel commented Jan 7, 2023

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;-)

@Daendaralus
Copy link

Daendaralus commented Mar 13, 2023

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 um Adaptive 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

@Argelbargel
Copy link
Author

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment