diff --git a/fritz_temp_sync/Dockerfile b/fritz_temp_sync/Dockerfile new file mode 100755 index 0000000..4880ab8 --- /dev/null +++ b/fritz_temp_sync/Dockerfile @@ -0,0 +1,17 @@ +ARG BUILD_FROM +FROM $BUILD_FROM + +# Install requirements for add-on +RUN apk update && apk add --no-cache python3 py-pip +RUN python3 -m pip install websocket-client requests + +WORKDIR /data + +# Copy data for add-on +COPY sync_ha_fb.py /srv +COPY fritzbox.py /srv +COPY homeassistant.py /srv +COPY run.sh / +RUN chmod a+x /run.sh + +CMD [ "/run.sh" ] \ No newline at end of file diff --git a/fritz_temp_sync/config.yaml b/fritz_temp_sync/config.yaml new file mode 100755 index 0000000..7644348 --- /dev/null +++ b/fritz_temp_sync/config.yaml @@ -0,0 +1,25 @@ +name: "Fritz!Box Temperature Sync" +description: "Sync Fritz!DECT thermostate temperatures with other sensors in Home Assistant" +version: "0.1.0" +slug: "fritz_temp_sync" +homeassistant_api: true +arch: + - aarch64 + - amd64 + - armhf + - armv7 + - i386 +options: + fritzbox: + url: "http://fritz.box" + password: null + mappings: + - sensor: null + thermostate: null +schema: + fritzbox: + url: url + password: str + mappings: + - sensor: str + thermostate: str \ No newline at end of file diff --git a/fritz_temp_sync/fritzbox.py b/fritz_temp_sync/fritzbox.py new file mode 100755 index 0000000..df5ea0c --- /dev/null +++ b/fritz_temp_sync/fritzbox.py @@ -0,0 +1,166 @@ +from typing import Optional, Tuple +import requests +import json +import re +import hashlib +import xml.etree.ElementTree as ET +import logging + +class FritzBox: + def __init__(self, url:str, password:str, user:str = None) -> None: + self._endpoints = { + "login": "login_sid.lua?version=2", + "logout": "index.lua", + "data": "data.lua" + } + self.url = url + self.session = requests.Session() + self.password = password + self.sid = None + + def _calc_challenge_v2(self, challenge: str) -> str: + + logging.debug(f"Calculate v2 challenge: {challenge}") + chall_regex = re.compile("2\$(?P[0-9a-zA-Z]+)\$(?P[0-9a-zA-Z]+)\$(?P[0-9a-zA-Z]+)\$(?P[0-9a-zA-Z]+)") + + chall_parts = chall_regex.match(challenge).groupdict() + salt1: bytes = bytes.fromhex(chall_parts["salt1"]) + iter1: int = int(chall_parts["iter1"]) + salt2: bytes = bytes.fromhex(chall_parts["salt2"]) + iter2: int = int(chall_parts["iter2"]) + + hash1 = hashlib.pbkdf2_hmac('sha256', self.password.encode(), salt1, iter1) + response = salt2.hex() + "$" + hashlib.pbkdf2_hmac('sha256', hash1, salt2, iter2).hex() + + return response + + def _calc_challenge_v1(self, challenge: str) -> str: + """ Calculate the response for a challenge using legacy MD5 """ + logging.debug(f"Calculate v1 challenge: {challenge}") + response = f"{challenge}-{self.password}" + response = response.encode("utf_16_le") + response = challenge + "-" + hashlib.md5(response).hexdigest() + return response + + def login(self, user:str = None) -> bool: + logging.debug(f"login user {user}") + challenge = None + r = self.session.get(f"{self.url}/{self._endpoints['login']}") + xml = ET.fromstring(r.text) + for elem in xml: + if elem.tag == "SID": + self.sid = elem.text + elif elem.tag == "Challenge": + challenge = elem.text + elif user is None and elem.tag == "Users": + for user_elem in elem: + if "fritz" in user_elem.text: + user = user_elem.text + + assert challenge is not None and user is not None + + if challenge.startswith("2$"): + response = self._calc_challenge_v2(challenge) + else: + response = self._calc_challenge_v1(challenge) + + data = { + "username": user, + "response": response + } + + r = self.session.post(f"{self.url}/{self._endpoints['login']}", data=data) + logging.debug(r.text) + xml = ET.fromstring(r.text) + for elem in xml: + if elem.tag == "SID": + self.sid = elem.text + + logging.debug(f"Authenticated fritzbox: {len(self.sid) != self.sid.count('0')}") + return len(self.sid) != self.sid.count("0") + + def logout(self) -> bool: + logging.debug("logout") + data = { + "xhr":1, + "sid": self.sid, + "logout": 1, + "no_sidrenew":""} + r = self.session.post(f"{self.url}/{self._endpoints['logout']}", data=data) + + return r.status_code == 200 + + def list_devices(self): + data = { + "xhr": 1, + "sid": self.sid, + "lang": "de", + "page":"sh_dev", + "xhrId": "all" + } + r = self.session.post(f"{self.url}/{self._endpoints['data']}", data=data) + logging.debug(r.text[:100]) + if len(r.history) > 0: + if self.login(): + r = self.session.post(f"{self.url}/{self._endpoints['data']}", data=data) + else: + return None + devices = json.loads(r.text)["data"]["devices"] + + return devices + + def get_device_data(self, id: int = None, name: str = None) -> Optional[Tuple[float, float, int, str]]: + if id is None and name is None: + logging.debug("No id or name given") + return None + + devices = self.list_devices() + for device in devices: + if device["id"] == id or device["displayName"] == name: + break + device = None + + if device is None: + logging.debug(f"Device {id} {name} not found") + return None + + current_temp = None + current_offset = None + for unit in device["units"]: + if unit["type"] == "TEMPERATURE_SENSOR": + for skill in unit["skills"]: + if skill["type"] == "SmartHomeTemperatureSensor": + current_temp = float(skill["currentInCelsius"]) + current_offset = float(skill["offset"]) + + return current_temp, current_offset, device["id"], device["displayName"] + + def set_offset(self, current_temp: str, offset: float, device_id: int, device_name: str): + data = { + "xhr": 1, + "sid": self.sid, + "lang": "de", + "device": device_id, + "page": "home_auto_hkr_edit" + } + r = self.session.post(f"{self.url}/{self._endpoints['data']}", data=data) + + data = { + "xhr":1, + "sid": self.sid, + "lang": "de", + "device": device_id, + "view": "", + "back_to_page": "sh_dev", + "ule_device_name": device_name, + "WindowOpenTrigger":8, + "WindowOpenTimer":10, + "tempsensor": "own", + "Roomtemp": f"{current_temp}", + "ExtTempsensorID":"tochoose", + "Offset": f"{offset}", + "apply":"", + "oldpage":"/net/home_auto_hkr_edit.lua" + } + + r = self.session.post(f"{self.url}/{self._endpoints['data']}", data=data) diff --git a/fritz_temp_sync/homeassistant.py b/fritz_temp_sync/homeassistant.py new file mode 100755 index 0000000..d694979 --- /dev/null +++ b/fritz_temp_sync/homeassistant.py @@ -0,0 +1,66 @@ +from __future__ import annotations + +import json +import logging +from typing import Callable +import websocket +import time + +class HomeAssistantAPI: + def __init__(self, token:str, initialize: Callable[[HomeAssistantAPI], None]) -> None: + self.token = token + self.msg_id = 1 + self.ws = None + self.subscriptions = {} + self.init_callback = initialize + + def handle_message(self, ws: websocket.WebSocket, msg: str) -> None: + if self.ws is None: + self.ws = ws + + message: object = json.loads(msg) + + if message["type"] == "auth_required": + response = { + "type": "auth", + "access_token": self.token + } + logging.debug(response) + ws.send(json.dumps(response)) + return + elif message["type"] == "auth_invalid": + logging.info("Auth failed") + ws.close() + return None + elif message["type"] == "auth_ok": + logging.debug("Authenticated") + self.init_callback(self) + self.init_callback = None + return + elif message["type"] == "event": + event = message["event"] + if event["event_type"] in self.subscriptions.keys(): + self.subscriptions[event["event_type"]](event) + + else: + print("Received", message) + + def subscribe_event(self, event_type: str, callback: Callable[[object], None]): + + if self.ws is None: + logging.debug("Websocket not set") + return + + if event_type in self.subscriptions.keys(): + logging.warning(f"Already subscribed to {event_type}") + return + + logging.info(f"Subscribe to {event_type}") + self.subscriptions[event_type] = callback + response = { + "id": self.msg_id, + "type": "subscribe_events", + "event_type": event_type + } + self.msg_id += 1 + self.ws.send(json.dumps(response)) diff --git a/fritz_temp_sync/run.sh b/fritz_temp_sync/run.sh new file mode 100755 index 0000000..57ac955 --- /dev/null +++ b/fritz_temp_sync/run.sh @@ -0,0 +1,3 @@ +#!/usr/bin/env bashio + +python3 /srv/sync_ha_fb.py \ No newline at end of file diff --git a/fritz_temp_sync/sync_ha_fb.py b/fritz_temp_sync/sync_ha_fb.py new file mode 100755 index 0000000..8ca2a44 --- /dev/null +++ b/fritz_temp_sync/sync_ha_fb.py @@ -0,0 +1,78 @@ +#!/usr/bin/env python3 +""" +hier die verbindungen zu HA aufbauen etc +außerdem das vergleichen der werte und dass anstoßen der updates +""" +import os +from fritzbox import FritzBox +from homeassistant import HomeAssistantAPI +import logging +import websocket +import json + +mappings = {} + +def handle_event(event): + entity_id = event["data"]["entity_id"] + if entity_id in mappings.keys(): + new_state = event["data"]["new_state"] + logging.debug(entity_id) + logging.debug(new_state["attributes"]["temperature"]) + rounded = round(float(new_state["attributes"]["temperature"])*2)/2 + logging.debug(rounded) + if new_state["attributes"]["device_class"] == "temperature": + if entity_id in mappings.keys(): + fb.login() + logged = False + for thermostate in mappings[entity_id]: + current_temp, current_offset, id, name = fb.get_device_data(name=thermostate) + if not logged: + logging.info(f"Current measurement from {entity_id}: {new_state['attributes']['temperature']} ({rounded})") + logged = True + logging.info(f"Current measurement from {thermostate}: {current_temp}") + new_offset = current_offset + rounded - current_temp + if new_offset != current_offset: + old_offset = current_offset + logging.debug(f"Set offset for {thermostate} from {current_offset} to {new_offset}") + fb.set_offset(current_temp, new_offset, id, name) + current_temp, current_offset, id, name = fb.get_device_data(name=thermostate) + logging.debug(f"Target: {new_offset} ; Set: {current_offset}") + if new_offset == current_offset: + logging.info(f"Adjustet offset from {old_offset} to {new_offset}") + else: + logging.warning(f"Failed to adjust offset from {old_offset} to {new_offset}") + fb.logout() + +def on_error(ws, error): + print(error) + +def on_close(ws, close_status_code, close_msg): + pass + +def on_open(ws): + pass + +def init(ha: HomeAssistantAPI): + logging.debug("Subscribe") + ha.subscribe_event("state_changed", handle_event) + + +logging.basicConfig(level=logging.INFO, format="[%(asctime)s] [%(levelname)s] %(message)s") +config = json.load(open("/data/options.json")) +logging.debug(config) +for mapping in config["mappings"]: + if mapping["sensor"] not in mappings.keys(): + mappings[mapping["sensor"]] = [] + mappings[mapping["sensor"]].append(mapping["thermostate"]) + +fb = FritzBox(config["fritzbox"]["url"], config["fritzbox"]["password"]) +ha = HomeAssistantAPI(os.environ["SUPERVISOR_TOKEN"], init) + +websocket.enableTrace(False) +ws = websocket.WebSocketApp("ws://supervisor/core/websocket", + on_open=on_open, + on_message=ha.handle_message, + on_error=on_error, + on_close=on_close) + +ws.run_forever()