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()
@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