First version sync addon
This commit is contained in:
parent
6b06cfb125
commit
1f0a829831
17
fritz_temp_sync/Dockerfile
Executable file
17
fritz_temp_sync/Dockerfile
Executable 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
25
fritz_temp_sync/config.yaml
Executable 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
166
fritz_temp_sync/fritzbox.py
Executable 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)
|
66
fritz_temp_sync/homeassistant.py
Executable file
66
fritz_temp_sync/homeassistant.py
Executable 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
3
fritz_temp_sync/run.sh
Executable 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
78
fritz_temp_sync/sync_ha_fb.py
Executable 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()
|
Loading…
x
Reference in New Issue
Block a user