First version sync addon

This commit is contained in:
Micha Horlboge 2022-01-24 10:47:34 +01:00
parent 6b06cfb125
commit 1f0a829831
6 changed files with 355 additions and 0 deletions

17
fritz_temp_sync/Dockerfile Executable file
View File

@ -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" ]

25
fritz_temp_sync/config.yaml Executable file
View File

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

166
fritz_temp_sync/fritzbox.py Executable file
View File

@ -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<iter1>[0-9a-zA-Z]+)\$(?P<salt1>[0-9a-zA-Z]+)\$(?P<iter2>[0-9a-zA-Z]+)\$(?P<salt2>[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)

View File

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

3
fritz_temp_sync/run.sh Executable file
View File

@ -0,0 +1,3 @@
#!/usr/bin/env bashio
python3 /srv/sync_ha_fb.py

78
fritz_temp_sync/sync_ha_fb.py Executable file
View File

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