Skip to content

Instantly share code, notes, and snippets.

@Argelbargel
Created September 13, 2024 10:57
Show Gist options
  • Save Argelbargel/ee2acc2039dd29c4d65d1d1b44ac9143 to your computer and use it in GitHub Desktop.
Save Argelbargel/ee2acc2039dd29c4d65d1d1b44ac9143 to your computer and use it in GitHub Desktop.
OpenHAB-Rule to update temperature offsets of AVM Fritz! DECT Radiator Thermostats (FritzOS >= 7.50)
// *************************************************************************************************************************************
// 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 sensitivity
def FritzboxThermostat(def id, def displayName, def offset, def correctedCelsius, def doNotHeatOffsetInMinutes, def sensitivity) {
this.id = id
this.displayName = displayName
this.offset = offset
this.correctedCelsius = correctedCelsius
this.doNotHeatOffsetInMinutes = doNotHeatOffsetInMinutes
this.sensitivity = sensitivity
}
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 getWindowOpenTrigger() {
if (sensitivity < 5) {
return 4
}
if (sensitivity < 9) {
return 8
}
return 12
}
def updateOffset(def newOffset) {
return new FritzboxThermostat(id, displayName, newOffset, correctedCelsius - (offset - newOffset), doNotHeatOffsetInMinutes, sensitivity)
}
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, (int) windowOpenSensor.sensitivity)
}
.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.debug("measured average temperatures: " + temperatures)
LOG.info("thermostat settings: " + thermostats)
LOG.debug(offsets as String)
LOG.info(roundedOffsets as String)
def thermostatsToUpdate = thermostats
.findAll { roundedOffsets.containsKey(it.key) && 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 ->
LOG.debug("xhr=1&sid=${fritzboxLoginState.sid}&device=${t.id}&view=&back_to_page=%2Fsmarthome%2Fdevices.lua&ule_device_name=${t.uleDeviceName}&WindowOpenTrigger=${t.windowOpenTrigger}&WindowOpenTimer=10&tempsensor=own&Roomtemp=${t.roomTemp + 1}&ExtTempsensorID=tochoose&Offset=${t.offset}&apply=&lang=de&page=home_auto_hkr_edit")
HTTP.sendHttpPostRequest(
"http://${fritzboxLoginState.host}/data.lua",
"application/x-www-form-urlencoded",
"xhr=1&sid=${fritzboxLoginState.sid}&device=${t.id}&view=&back_to_page=%2Fsmarthome%2Fdevices.lua&ule_device_name=${t.uleDeviceName}&WindowOpenTrigger=${t.windowOpenTrigger}&WindowOpenTimer=10&tempsensor=own&Roomtemp=${t.roomTemp + 1}&ExtTempsensorID=tochoose&Offset=${t.offset}&apply=&lang=de&page=home_auto_hkr_edit",
10000
)
}
LOG.info("new thermostat settings: " + getThermostats(fritzboxLoginState))
fritzboxLoginState.logout()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment