Change to asyncio, keep connection to fb alive, use data from ha for comparing

This commit is contained in:
Micha Horlboge 2022-01-28 22:37:28 +01:00
parent 1f0a829831
commit 9bf21d04e3
4 changed files with 301 additions and 119 deletions

1
.gitignore vendored
View File

@ -15,6 +15,7 @@ dist/
downloads/ downloads/
eggs/ eggs/
.eggs/ .eggs/
.venv/
lib/ lib/
lib64/ lib64/
parts/ parts/

View File

@ -1,4 +1,7 @@
from typing import Optional, Tuple import asyncio
from asyncio import Task
from datetime import datetime, timedelta
from typing import Optional, Tuple, Dict
import requests import requests
import json import json
import re import re
@ -6,23 +9,34 @@ import hashlib
import xml.etree.ElementTree as ET import xml.etree.ElementTree as ET
import logging import logging
class FritzBox: class FritzBox:
def __init__(self, url:str, password:str, user:str = None) -> None: def __init__(self, url: str, password: str, user: str = None) -> None:
self._endpoints = { self._endpoints = {
"login": "login_sid.lua?version=2", "login": "login_sid.lua?version=2",
"logout": "index.lua", "logout": "index.lua",
"data": "data.lua" "data": "data.lua"
} }
self.url = url self.url: str = url
self.session = requests.Session() self.user: Optional[str] = user
self.password = password self.session: requests.Session = requests.Session()
self.sid = None self.password: str = password
self.sid: Optional[str] = None
self.update_time: Dict[str, datetime] = {}
self.hold_connection: Optional[Task] = None
async def hold_connection_alive(self) -> None:
while True:
# Session automatically destroyed after 20m of inactivity
await asyncio.sleep(19*60)
self.check_session()
def _calc_challenge_v2(self, challenge: str) -> str: def _calc_challenge_v2(self, challenge: str) -> str:
logging.debug(f"Calculate v2 challenge: {challenge}") 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_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() chall_parts = chall_regex.match(challenge).groupdict()
salt1: bytes = bytes.fromhex(chall_parts["salt1"]) salt1: bytes = bytes.fromhex(chall_parts["salt1"])
iter1: int = int(chall_parts["iter1"]) iter1: int = int(chall_parts["iter1"])
@ -42,8 +56,24 @@ class FritzBox:
response = challenge + "-" + hashlib.md5(response).hexdigest() response = challenge + "-" + hashlib.md5(response).hexdigest()
return response return response
def login(self, user:str = None) -> bool: def check_session(self) -> None:
logging.debug(f"login user {user}") data = {
"xhr": 1,
"sid": self.sid,
"lang": "de",
"page": "overview",
"xhrId": "first",
"noMenuRef": 1
}
r = self.session.post(f"{self.url}/{self._endpoints['data']}", data=data)
if len(r.history) > 0:
if not self.login():
logging.error("Failed to login to Fritz!Box")
else:
logging.info("Already logged in")
def login(self, user: str = None) -> bool:
logging.info(f"Login user {user} to Fritz!Box")
challenge = None challenge = None
r = self.session.get(f"{self.url}/{self._endpoints['login']}") r = self.session.get(f"{self.url}/{self._endpoints['login']}")
xml = ET.fromstring(r.text) xml = ET.fromstring(r.text)
@ -65,7 +95,7 @@ class FritzBox:
response = self._calc_challenge_v1(challenge) response = self._calc_challenge_v1(challenge)
data = { data = {
"username": user, "username": user,
"response": response "response": response
} }
@ -75,27 +105,31 @@ class FritzBox:
for elem in xml: for elem in xml:
if elem.tag == "SID": if elem.tag == "SID":
self.sid = elem.text self.sid = elem.text
logging.debug(f"Authenticated fritzbox: {len(self.sid) != self.sid.count('0')}") logging.info(f"Authenticated Fritz!Box: {len(self.sid) != self.sid.count('0')}")
if len(self.sid) != self.sid.count("0"):
self.hold_connection = asyncio.create_task(self.hold_connection_alive())
return len(self.sid) != self.sid.count("0") return len(self.sid) != self.sid.count("0")
def logout(self) -> bool: def logout(self) -> bool:
logging.debug("logout") logging.info("logout")
data = { data = {
"xhr":1, "xhr": 1,
"sid": self.sid, "sid": self.sid,
"logout": 1, "logout": 1,
"no_sidrenew":""} "no_sidrenew": ""}
r = self.session.post(f"{self.url}/{self._endpoints['logout']}", data=data) r = self.session.post(f"{self.url}/{self._endpoints['logout']}", data=data)
if self.hold_connection is not None:
self.hold_connection.cancel()
return r.status_code == 200 return r.status_code == 200
def list_devices(self): def list_devices(self) -> Optional[Dict]:
data = { data = {
"xhr": 1, "xhr": 1,
"sid": self.sid, "sid": self.sid,
"lang": "de", "lang": "de",
"page":"sh_dev", "page": "sh_dev",
"xhrId": "all" "xhrId": "all"
} }
r = self.session.post(f"{self.url}/{self._endpoints['data']}", data=data) r = self.session.post(f"{self.url}/{self._endpoints['data']}", data=data)
@ -106,7 +140,7 @@ class FritzBox:
else: else:
return None return None
devices = json.loads(r.text)["data"]["devices"] devices = json.loads(r.text)["data"]["devices"]
return devices return devices
def get_device_data(self, id: int = None, name: str = None) -> Optional[Tuple[float, float, int, str]]: def get_device_data(self, id: int = None, name: str = None) -> Optional[Tuple[float, float, int, str]]:
@ -115,11 +149,12 @@ class FritzBox:
return None return None
devices = self.list_devices() devices = self.list_devices()
device = None
for device in devices: for device in devices:
if device["id"] == id or device["displayName"] == name: if device["id"] == id or device["displayName"] == name:
break break
device = None device = None
if device is None: if device is None:
logging.debug(f"Device {id} {name} not found") logging.debug(f"Device {id} {name} not found")
return None return None
@ -135,32 +170,42 @@ class FritzBox:
return current_temp, current_offset, device["id"], device["displayName"] return current_temp, current_offset, device["id"], device["displayName"]
def set_offset(self, current_temp: str, offset: float, device_id: int, device_name: str): def set_offset(self, current_temp: str, offset: float, device_id: int, device_name: str) -> None:
data = { data = {
"xhr": 1, "xhr": 1,
"sid": self.sid, "sid": self.sid,
"lang": "de", "lang": "de",
"device": device_id, "device": device_id,
"page": "home_auto_hkr_edit" "page": "home_auto_hkr_edit"
} }
r = self.session.post(f"{self.url}/{self._endpoints['data']}", data=data) r = self.session.post(f"{self.url}/{self._endpoints['data']}", data=data)
data = { data = {
"xhr":1, "xhr": 1,
"sid": self.sid, "sid": self.sid,
"lang": "de", "lang": "de",
"device": device_id, "device": device_id,
"view": "", "view": "",
"back_to_page": "sh_dev", "back_to_page": "sh_dev",
"ule_device_name": device_name, "ule_device_name": device_name,
"WindowOpenTrigger":8, "WindowOpenTrigger": 8,
"WindowOpenTimer":10, "WindowOpenTimer": 10,
"tempsensor": "own", "tempsensor": "own",
"Roomtemp": f"{current_temp}", "Roomtemp": f"{current_temp}",
"ExtTempsensorID":"tochoose", "ExtTempsensorID": "tochoose",
"Offset": f"{offset}", "Offset": f"{offset}",
"apply":"", "apply": "",
"oldpage":"/net/home_auto_hkr_edit.lua" "oldpage": "/net/home_auto_hkr_edit.lua"
} }
r = self.session.post(f"{self.url}/{self._endpoints['data']}", data=data) self.session.post(f"{self.url}/{self._endpoints['data']}", data=data)
def correct_offset(self, device_name: str, real_temp: float):
if device_name in self.update_time.keys():
logging.info(f"Last update for {device_name} {datetime.now() - self.update_time[device_name]} ago")
delta = timedelta(minutes=5)
if device_name not in self.update_time.keys() or (datetime.now() - self.update_time[device_name]) > delta:
current_temp, current_offset, idx, name = self.get_device_data(name=device_name)
new_offset = current_offset + real_temp - current_temp
logging.info(f"Should update offset from {current_offset} to {new_offset}")
self.update_time[device_name] = datetime.now()

View File

@ -1,66 +1,146 @@
from __future__ import annotations from __future__ import annotations
import asyncio
import json import json
import logging import logging
from typing import Callable from asyncio import Queue, Task, Event, Lock
import websocket from typing import Callable, Dict, Optional
import time import websockets
"""
- sender fun, bekommt packete per queue
- receiver fun, schreibt packete in map id -> msg bzw events in queue(?)
- blockieren beim auf antwort warten per "pseudo-queue", in die sich alle wartenden eintragen und warten, dass sie leer ist, receiver leert queue wenn irgendeine nachricht rein kommt
- andere (auch außerhalb) können dann auf neue daten warten und ggf verarbeiten
"""
class HomeAssistantAPI: class HomeAssistantAPI:
def __init__(self, token:str, initialize: Callable[[HomeAssistantAPI], None]) -> None: def __init__(self, token: str, url: str) -> None:
self.token = token self.token = token
self.msg_id = 1 self.msg_id = 1
self.ws = None self.msg_id_lock = Lock()
self.subscriptions = {} self.ws: websockets.WebSocketClientProtocol = None
self.init_callback = initialize self.url = url
self.receiver: Optional[Task] = None
self.sender: Optional[Task] = None
self.sending_queue: Queue = Queue()
self.authenticated: Event = Event()
self.events: Dict[int, Queue] = {}
self.responses: Dict[int, Dict] = {}
self.response_events: Dict[int, Event] = {}
self.response_lock: Lock = Lock()
def handle_message(self, ws: websocket.WebSocket, msg: str) -> None: async def connect(self):
if self.ws is None: self.ws = await websockets.connect(self.url)
self.ws = ws self.sender = asyncio.create_task(self.sending())
await self.auth()
self.receiver = asyncio.create_task(self.receiving())
message: object = json.loads(msg) async def wait_for_close(self):
await self.ws.wait_closed()
if message["type"] == "auth_required":
response = { async def receiving(self):
logging.debug("Start receiving")
async for message in self.ws:
msg: Dict = json.loads(message)
if msg["type"] == "event":
if msg["id"] not in self.events.keys():
logging.error(f"Received event for not subscribted id: {msg['id']} {msg['event_type']}")
continue
await self.events[msg["id"]].put(msg["event"])
else:
async with self.response_lock:
self.responses[msg["id"]] = msg
if msg["id"] in self.response_events.keys():
self.response_events[msg["id"]].set()
async def wait_for(self, idx):
async with self.response_lock:
if idx in self.responses.keys():
msg = self.responses[idx]
del self.responses[idx]
return msg
self.response_events[idx] = Event()
await self.response_events[idx].wait()
async with self.response_lock:
del self.response_events[idx]
if idx not in self.responses.keys():
logging.error("Response ID not found")
return None
msg = self.responses[idx]
del self.responses[idx]
return msg
async def exit_loop(self):
if self.sender is not None:
self.sender.cancel()
if self.receiver is not None:
self.receiver.cancel()
asyncio.get_running_loop().stop()
async def auth(self):
msg = json.loads(await self.ws.recv())
if msg["type"] != "auth_required":
logging.error("Authentication error: Not required")
await self.exit_loop()
response = {
"type": "auth", "type": "auth",
"access_token": self.token "access_token": self.token
} }
logging.debug(response) await self.sending_queue.put(response)
ws.send(json.dumps(response)) msg = json.loads(await self.ws.recv())
return if msg["type"] == "auth_invalid":
elif message["type"] == "auth_invalid":
logging.info("Auth failed") logging.info("Auth failed")
ws.close() await self.exit_loop()
return None elif msg["type"] == "auth_ok":
elif message["type"] == "auth_ok":
logging.debug("Authenticated") logging.debug("Authenticated")
self.init_callback(self) self.authenticated.set()
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: else:
print("Received", message) logging.error(f"Unknown answer for auth: {msg}")
await self.exit_loop()
def subscribe_event(self, event_type: str, callback: Callable[[object], None]):
if self.ws is None: async def sending(self):
logging.debug("Websocket not set") while msg := await self.sending_queue.get():
return await self.ws.send(json.dumps(msg))
async def subscribe_event(self, event_type: str):
await self.authenticated.wait()
if event_type in self.subscriptions.keys():
logging.warning(f"Already subscribed to {event_type}")
return
logging.info(f"Subscribe to {event_type}") logging.info(f"Subscribe to {event_type}")
self.subscriptions[event_type] = callback async with self.msg_id_lock:
response = { msg_id = self.msg_id
"id": self.msg_id, response = {
"type": "subscribe_events", "id": msg_id,
"event_type": event_type "type": "subscribe_events",
} "event_type": event_type
self.msg_id += 1 }
self.ws.send(json.dumps(response)) self.events[msg_id] = Queue()
self.msg_id += 1
await self.sending_queue.put(response)
return msg_id
async def get_states(self):
await self.authenticated.wait()
async with self.msg_id_lock:
message = {
"id": self.msg_id,
"type": "get_states"
}
self.msg_id += 1
await self.sending_queue.put(message)
response = await self.wait_for(message["id"])
# ToDo: Error handling
return response["result"]
async def get_device_state(self, entity_id: str):
device_states = await self.get_states()
for device_state in device_states:
if device_state["entity_id"] == entity_id:
return device_state
return None

View File

@ -3,31 +3,59 @@
hier die verbindungen zu HA aufbauen etc hier die verbindungen zu HA aufbauen etc
außerdem das vergleichen der werte und dass anstoßen der updates außerdem das vergleichen der werte und dass anstoßen der updates
""" """
import asyncio
import os import os
from typing import Dict
from fritzbox import FritzBox from fritzbox import FritzBox
from homeassistant import HomeAssistantAPI from homeassistant import HomeAssistantAPI
import logging import logging
import websocket
import json import json
mappings = {} sensor_mappings = {}
thermostate_mappings = {}
def handle_event(event): async def handle_event(idx: int):
entity_id = event["data"]["entity_id"] logging.debug(f"Wait for events for {idx}")
if entity_id in mappings.keys():
new_state = event["data"]["new_state"] while event := await ha.events[idx].get():
logging.debug(entity_id) entity_id = event["data"]["entity_id"]
logging.debug(new_state["attributes"]["temperature"]) if entity_id in sensor_mappings.keys() or entity_id in thermostate_mappings.keys():
rounded = round(float(new_state["attributes"]["temperature"])*2)/2 state = await ha.get_device_state(entity_id)
logging.debug(rounded) new_state = event["data"]["new_state"]
if new_state["attributes"]["device_class"] == "temperature": logging.info(f"received changed state from {entity_id}")
if entity_id in mappings.keys(): if entity_id in thermostate_mappings.keys() and state["state"] != "unavailable":
therm_temp = new_state["attributes"]["current_temperature"]
therm_name = new_state["attributes"]["friendly_name"]
sensor = thermostate_mappings[entity_id]
sensor_state = await ha.get_device_state(sensor)
sensor_temp = round(float(sensor_state["attributes"]["temperature"]) * 2) / 2
if therm_temp != sensor_temp:
logging.info(f"{therm_name}: {therm_temp}")
logging.info(f"{sensor}: {sensor_state['attributes']['temperature']} ({sensor_temp})")
fb.correct_offset(therm_name, sensor_temp)
elif entity_id in sensor_mappings.keys():
sensor_temp = round(float(new_state["attributes"]["temperature"]) * 2) / 2
"""
fb.login() fb.login()
logged = False logged = False
for thermostate in mappings[entity_id]: """
for thermostate in sensor_mappings[entity_id]:
therm_state = await ha.get_device_state(thermostate)
if therm_state["state"] == "unavailable":
continue
therm_temp = float(therm_state["attributes"]["current_temperature"])
therm_name = therm_state["attributes"]["friendly_name"]
if therm_temp != sensor_temp:
logging.info(f"{therm_name}: {therm_temp}")
logging.info(f"{entity_id}: {new_state['attributes']['temperature']} ({sensor_temp})")
fb.correct_offset(therm_name, sensor_temp)
"""
current_temp, current_offset, id, name = fb.get_device_data(name=thermostate) current_temp, current_offset, id, name = fb.get_device_data(name=thermostate)
if not logged: if not logged:
logging.info(f"Current measurement from {entity_id}: {new_state['attributes']['temperature']} ({rounded})") logging.info(
f"Current measurement from {entity_id}: {new_state['attributes']['temperature']} ({rounded})")
logged = True logged = True
logging.info(f"Current measurement from {thermostate}: {current_temp}") logging.info(f"Current measurement from {thermostate}: {current_temp}")
new_offset = current_offset + rounded - current_temp new_offset = current_offset + rounded - current_temp
@ -42,37 +70,65 @@ def handle_event(event):
else: else:
logging.warning(f"Failed to adjust offset from {old_offset} to {new_offset}") logging.warning(f"Failed to adjust offset from {old_offset} to {new_offset}")
fb.logout() fb.logout()
"""
def on_error(ws, error):
print(error)
def on_close(ws, close_status_code, close_msg): async def init(ha: HomeAssistantAPI):
pass await ha.connect()
def on_open(ws):
pass
def init(ha: HomeAssistantAPI):
logging.debug("Subscribe") logging.debug("Subscribe")
ha.subscribe_event("state_changed", handle_event) state_changed_id = await ha.subscribe_event("state_changed")
logging.debug(state_changed_id)
asyncio.create_task(handle_event(state_changed_id))
fb.login()
await ha.wait_for_close()
logging.info("Websocket closed, shutting down..")
asyncio.get_running_loop().stop()
async def migrate_config(config_path: str, ha: HomeAssistantAPI):
config = json.load(open(config_path))
therm_ids = {}
for state in await ha.get_states():
if state["entity_id"].startswith("climate.") and "friendly_name" in state["attributes"].keys():
therm_ids[state["attributes"]["friendly_name"]] = state["entity_id"]
mappings = []
for mapping in config["mappings"]:
if not mapping["thermostate"].startswith("climate."):
mapping["thermostate"] = therm_ids[mapping["thermostate"]]
mappings.append(mapping)
config["mappings"] = mappings
json.dump(open(config_path), config)
return config
logging.basicConfig(level=logging.INFO, format="[%(asctime)s] [%(levelname)s] %(message)s") logging.basicConfig(level=logging.INFO, format="[%(asctime)s] [%(levelname)s] %(message)s")
config = json.load(open("/data/options.json")) config_path = "/data/options.json"
config_path = "options.json"
config = json.load(open(config_path))
logging.debug(config) logging.debug(config)
for mapping in config["mappings"]:
if mapping["sensor"] not in mappings.keys():
mappings[mapping["sensor"]] = []
mappings[mapping["sensor"]].append(mapping["thermostate"])
loop = asyncio.get_event_loop()
fb = FritzBox(config["fritzbox"]["url"], config["fritzbox"]["password"]) fb = FritzBox(config["fritzbox"]["url"], config["fritzbox"]["password"])
ha = HomeAssistantAPI(os.environ["SUPERVISOR_TOKEN"], init) supervisor_url = "ws://supervisor/core/websocket"
supervisor_url = "ws://192.168.124.187:8123/api/websocket"
ha = HomeAssistantAPI(os.environ["SUPERVISOR_TOKEN"], supervisor_url)
websocket.enableTrace(False) if '"thermostate": "climate.' not in open(config_path).read():
ws = websocket.WebSocketApp("ws://supervisor/core/websocket", config = loop.run_until_complete(migrate_config(config_path, ha))
on_open=on_open, logging.info(config)
on_message=ha.handle_message, exit()
on_error=on_error,
on_close=on_close)
ws.run_forever() for mapping in config["mappings"]:
if mapping["sensor"] not in sensor_mappings.keys():
sensor_mappings[mapping["sensor"]] = []
sensor_mappings[mapping["sensor"]].append(mapping["thermostate"])
thermostate_mappings[mapping["thermostate"]] = mapping["sensor"]
loop.create_task(init(ha))
try:
loop.run_forever()
except KeyboardInterrupt:
pass