Created
September 13, 2024 10:57
-
-
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)
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// ************************************************************************************************************************************* | |
// 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