Last active
September 13, 2024 10:59
-
-
Save Argelbargel/bfbcbc09e90bf2bac3d9a3ccaeb2506b 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 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 @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...
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
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:
http://fritz.box/data.lua
{'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': ....., ... }
{'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']
"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