Improve stability etc
This commit is contained in:
parent
5aa94a3c97
commit
4aaddf23e0
2
.gitignore
vendored
2
.gitignore
vendored
@ -244,3 +244,5 @@ fabric.properties
|
|||||||
# local config test
|
# local config test
|
||||||
options.json
|
options.json
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|
||||||
|
options.json
|
||||||
@ -2,7 +2,7 @@ ARG BUILD_FROM
|
|||||||
FROM $BUILD_FROM
|
FROM $BUILD_FROM
|
||||||
|
|
||||||
# Install requirements for add-on
|
# Install requirements for add-on
|
||||||
RUN apk update && apk add --no-cache python3 py3-pip py3-websockets py3-requests
|
RUN apk update && apk add --no-cache python3 py3-pip py3-websockets py3-httpx
|
||||||
|
|
||||||
WORKDIR /data
|
WORKDIR /data
|
||||||
|
|
||||||
|
|||||||
56
fritz_temp_sync/README.md
Normal file
56
fritz_temp_sync/README.md
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
# Fritz!Box Temperature Sync
|
||||||
|
|
||||||
|
Dieses Add-on synchronisiert die gemessene Temperatur von Home-Assistant-Sensoren mit Fritz!DECT-Thermostaten, indem die Offset-Korrektur an der Fritz!Box angepasst wird.
|
||||||
|
|
||||||
|
## Voraussetzungen
|
||||||
|
- Home Assistant Add-on Umgebung
|
||||||
|
- Fritz!Box mit Fritz!DECT-Thermostaten
|
||||||
|
- Home Assistant Entitäten für Thermostate und Temperatursensoren
|
||||||
|
|
||||||
|
## Konfiguration
|
||||||
|
Die Optionen werden über das Add-on-Formular oder die `options.json` gesetzt.
|
||||||
|
|
||||||
|
### Optionen
|
||||||
|
- `fritzbox.url` (URL, erforderlich): Basis-URL der Fritz!Box, z. B. `http://fritz.box`
|
||||||
|
- `fritzbox.username` (string, optional): Benutzername
|
||||||
|
- `fritzbox.password` (string, erforderlich): Passwort
|
||||||
|
- `mappings` (Liste, erforderlich): Zuordnung von Sensor → Thermostat
|
||||||
|
- `update_timeout` (int, erforderlich): Mindestabstand in Minuten zwischen Offset-Updates pro Thermostat
|
||||||
|
- `log_level` (string, optional): z. B. `DEBUG`, `INFO`, `WARNING`
|
||||||
|
- `offset_threshold` (float, optional): Mindestabweichung in °C, ab der ein Offset gesetzt wird (Default 0.5)
|
||||||
|
- `dry_run` (bool, optional): Wenn `true`, werden keine Änderungen an der Fritz!Box vorgenommen
|
||||||
|
|
||||||
|
### Beispiel
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"fritzbox": {
|
||||||
|
"url": "http://fritz.box",
|
||||||
|
"username": "ha",
|
||||||
|
"password": "***"
|
||||||
|
},
|
||||||
|
"mappings": [
|
||||||
|
{
|
||||||
|
"sensor": "sensor.room_temperature",
|
||||||
|
"thermostate": "climate.room"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"update_timeout": 15,
|
||||||
|
"log_level": "INFO",
|
||||||
|
"offset_threshold": 0.5,
|
||||||
|
"dry_run": false
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Verhalten
|
||||||
|
- Bei jedem relevanten `state_changed`-Event wird die Differenz zwischen Thermostat- und Sensorwert geprüft.
|
||||||
|
- Erst ab `offset_threshold` erfolgt eine Offset-Korrektur.
|
||||||
|
- Pro Thermostat wird maximal alle `update_timeout` Minuten ein Update durchgeführt.
|
||||||
|
|
||||||
|
## Logging
|
||||||
|
- `INFO`: Start/Stop, erfolgreiche Authentifizierung, Offset-Korrekturen
|
||||||
|
- `WARNING`: Verbindungsprobleme, Retries, Dry-Run-Hinweise
|
||||||
|
- `DEBUG`: Detailzustände und Entscheidungslogik
|
||||||
|
|
||||||
|
## Hinweise
|
||||||
|
- Das Add-on schreibt keine Payload-Snapshots mehr auf die Platte.
|
||||||
|
- Die Zuordnung erfolgt über die Entity-ID-Namen aus Home Assistant.
|
||||||
145
fritz_temp_sync/config.py
Normal file
145
fritz_temp_sync/config.py
Normal file
@ -0,0 +1,145 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Any, Iterable, Optional, cast
|
||||||
|
|
||||||
|
from errors import ConfigError
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Mapping:
|
||||||
|
sensor: str
|
||||||
|
thermostate: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class FritzBoxConfig:
|
||||||
|
url: str
|
||||||
|
password: str
|
||||||
|
username: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class AppConfig:
|
||||||
|
fritzbox: FritzBoxConfig
|
||||||
|
mappings: list[Mapping]
|
||||||
|
update_timeout: int
|
||||||
|
log_level: str = "INFO"
|
||||||
|
offset_threshold: float = 0.5
|
||||||
|
dry_run: bool = False
|
||||||
|
force_ipv4: bool = True
|
||||||
|
log_ws_messages: bool = False
|
||||||
|
log_http_requests: bool = False
|
||||||
|
request_timeout: int = 10
|
||||||
|
request_retries: int = 2
|
||||||
|
|
||||||
|
|
||||||
|
def load_config(path: str) -> AppConfig:
|
||||||
|
with open(path, "r", encoding="utf-8") as config_file:
|
||||||
|
raw = json.load(config_file)
|
||||||
|
|
||||||
|
if not isinstance(raw, dict):
|
||||||
|
raise ConfigError("Config must be a JSON object")
|
||||||
|
raw = cast(dict[str, Any], raw)
|
||||||
|
|
||||||
|
fritzbox = raw.get("fritzbox")
|
||||||
|
if not isinstance(fritzbox, dict):
|
||||||
|
raise ConfigError("Missing or invalid 'fritzbox' config")
|
||||||
|
fritzbox = cast(dict[str, Any], fritzbox)
|
||||||
|
|
||||||
|
url = fritzbox.get("url")
|
||||||
|
password = fritzbox.get("password")
|
||||||
|
username = fritzbox.get("username")
|
||||||
|
|
||||||
|
if not isinstance(url, str) or not url:
|
||||||
|
raise ConfigError("Missing or invalid 'fritzbox.url' config")
|
||||||
|
if not isinstance(password, str) or not password:
|
||||||
|
raise ConfigError("Missing or invalid 'fritzbox.password' config")
|
||||||
|
if username is not None and not isinstance(username, str):
|
||||||
|
raise ConfigError("Invalid 'fritzbox.username' config")
|
||||||
|
|
||||||
|
mappings = _parse_mappings(raw.get("mappings"))
|
||||||
|
|
||||||
|
update_timeout = raw.get("update_timeout")
|
||||||
|
if not isinstance(update_timeout, int):
|
||||||
|
raise ConfigError("Missing or invalid 'update_timeout' config")
|
||||||
|
|
||||||
|
log_level = raw.get("log_level", "INFO")
|
||||||
|
if not isinstance(log_level, str):
|
||||||
|
raise ConfigError("Invalid 'log_level' config")
|
||||||
|
|
||||||
|
offset_threshold = raw.get("offset_threshold", 0.5)
|
||||||
|
if not isinstance(offset_threshold, (int, float)):
|
||||||
|
raise ConfigError("Invalid 'offset_threshold' config")
|
||||||
|
|
||||||
|
dry_run = raw.get("dry_run", False)
|
||||||
|
if not isinstance(dry_run, bool):
|
||||||
|
raise ConfigError("Invalid 'dry_run' config")
|
||||||
|
|
||||||
|
force_ipv4 = raw.get("force_ipv4", True)
|
||||||
|
if not isinstance(force_ipv4, bool):
|
||||||
|
raise ConfigError("Invalid 'force_ipv4' config")
|
||||||
|
|
||||||
|
log_ws_messages = raw.get("log_ws_messages", False)
|
||||||
|
if not isinstance(log_ws_messages, bool):
|
||||||
|
raise ConfigError("Invalid 'log_ws_messages' config")
|
||||||
|
|
||||||
|
log_http_requests = raw.get("log_http_requests", False)
|
||||||
|
if not isinstance(log_http_requests, bool):
|
||||||
|
raise ConfigError("Invalid 'log_http_requests' config")
|
||||||
|
|
||||||
|
request_timeout = raw.get("request_timeout", 10)
|
||||||
|
if not isinstance(request_timeout, int):
|
||||||
|
raise ConfigError("Invalid 'request_timeout' config")
|
||||||
|
|
||||||
|
request_retries = raw.get("request_retries", 2)
|
||||||
|
if not isinstance(request_retries, int):
|
||||||
|
raise ConfigError("Invalid 'request_retries' config")
|
||||||
|
|
||||||
|
return AppConfig(
|
||||||
|
fritzbox=FritzBoxConfig(url=url, password=password, username=username),
|
||||||
|
mappings=mappings,
|
||||||
|
update_timeout=update_timeout,
|
||||||
|
log_level=log_level,
|
||||||
|
offset_threshold=float(offset_threshold),
|
||||||
|
dry_run=dry_run,
|
||||||
|
force_ipv4=force_ipv4,
|
||||||
|
log_ws_messages=log_ws_messages,
|
||||||
|
log_http_requests=log_http_requests,
|
||||||
|
request_timeout=request_timeout,
|
||||||
|
request_retries=request_retries,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_mappings(value: Any) -> list[Mapping]:
|
||||||
|
if not isinstance(value, list):
|
||||||
|
raise ConfigError("Missing or invalid 'mappings' config")
|
||||||
|
value = cast(list[Any], value)
|
||||||
|
mappings: list[Mapping] = []
|
||||||
|
seen_pairs: set[tuple[str, str]] = set()
|
||||||
|
seen_thermostats: set[str] = set()
|
||||||
|
for idx, entry in enumerate(_iter_dicts(value)):
|
||||||
|
sensor = entry.get("sensor")
|
||||||
|
thermostate = entry.get("thermostate")
|
||||||
|
if not isinstance(sensor, str) or not isinstance(thermostate, str):
|
||||||
|
raise ConfigError(f"Invalid mapping at index {idx}")
|
||||||
|
if thermostate in seen_thermostats:
|
||||||
|
raise ConfigError(
|
||||||
|
f"Duplicate thermostate mapping at index {idx}: {thermostate}"
|
||||||
|
)
|
||||||
|
pair = (sensor, thermostate)
|
||||||
|
if pair in seen_pairs:
|
||||||
|
raise ConfigError(f"Duplicate mapping at index {idx}: {pair}")
|
||||||
|
seen_thermostats.add(thermostate)
|
||||||
|
seen_pairs.add(pair)
|
||||||
|
mappings.append(Mapping(sensor=sensor, thermostate=thermostate))
|
||||||
|
if not mappings:
|
||||||
|
raise ConfigError("'mappings' config must not be empty")
|
||||||
|
return mappings
|
||||||
|
|
||||||
|
|
||||||
|
def _iter_dicts(items: Iterable[Any]) -> Iterable[dict[str, Any]]:
|
||||||
|
for item in items:
|
||||||
|
if isinstance(item, dict):
|
||||||
|
yield item
|
||||||
@ -1,9 +1,9 @@
|
|||||||
name: "Fritz!Box Temperature Sync Dev"
|
name: "Fritz!Box Temperature Sync Dev"
|
||||||
description: "Sync Fritz!DECT thermostat temperatures with other sensors in Home Assistant"
|
description: "Sync Fritz!DECT thermostat temperatures with other sensors in Home Assistant"
|
||||||
version: "0.4.3"
|
version: "0.5"
|
||||||
startup: "application"
|
startup: "application"
|
||||||
stage: "stable"
|
stage: "stable"
|
||||||
slug: "fritz_temp_sync_dev"
|
slug: "fritz_temp_sync"
|
||||||
homeassistant_api: true
|
homeassistant_api: true
|
||||||
init: false
|
init: false
|
||||||
arch:
|
arch:
|
||||||
@ -20,6 +20,14 @@ options:
|
|||||||
- sensor: null
|
- sensor: null
|
||||||
thermostate: null
|
thermostate: null
|
||||||
update_timeout: 15
|
update_timeout: 15
|
||||||
|
log_level: "INFO"
|
||||||
|
offset_threshold: 0.5
|
||||||
|
dry_run: false
|
||||||
|
force_ipv4: true
|
||||||
|
log_ws_messages: false
|
||||||
|
log_http_requests: false
|
||||||
|
request_timeout: 10
|
||||||
|
request_retries: 2
|
||||||
schema:
|
schema:
|
||||||
fritzbox:
|
fritzbox:
|
||||||
url: url
|
url: url
|
||||||
@ -30,3 +38,10 @@ schema:
|
|||||||
thermostate: str
|
thermostate: str
|
||||||
update_timeout: int
|
update_timeout: int
|
||||||
log_level: "str?"
|
log_level: "str?"
|
||||||
|
offset_threshold: float?
|
||||||
|
dry_run: bool?
|
||||||
|
force_ipv4: bool?
|
||||||
|
log_ws_messages: bool?
|
||||||
|
log_http_requests: bool?
|
||||||
|
request_timeout: int?
|
||||||
|
request_retries: int?
|
||||||
|
|||||||
@ -1,714 +1,276 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from abc import ABC, abstractmethod
|
from dataclasses import dataclass
|
||||||
from enum import Enum, IntFlag, auto
|
from typing import Any, Dict, List, Optional, cast
|
||||||
from typing import Dict, List, Optional, Union
|
|
||||||
import typing
|
|
||||||
|
|
||||||
|
|
||||||
class WeekDay(IntFlag):
|
@dataclass
|
||||||
MON = 0b1
|
class TemperatureSensor:
|
||||||
TUE = 0b10
|
current_celsius: Optional[float]
|
||||||
WED = 0b100
|
offset: Optional[float]
|
||||||
THU = 0b1000
|
|
||||||
FRI = 0b10000
|
|
||||||
SAT = 0b100000
|
|
||||||
SUN = 0b1000000
|
|
||||||
|
|
||||||
|
|
||||||
class Manufacturer:
|
@dataclass
|
||||||
def __init__(self, name: str):
|
class Device:
|
||||||
self.name: str = name
|
id: int
|
||||||
|
display_name: str
|
||||||
|
temp_sensor: TemperatureSensor
|
||||||
|
raw_state: Dict[str, Any]
|
||||||
|
|
||||||
def __repr__(self):
|
def __init__(self, state: Dict[str, Any]):
|
||||||
return f"name: {self.name}"
|
self.raw_state = state
|
||||||
|
id_value = state.get("id")
|
||||||
def to_json(self):
|
if isinstance(id_value, int):
|
||||||
return {"name": self.name}
|
self.id = id_value
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def parse_dict(manufacturer: Dict):
|
|
||||||
return Manufacturer(manufacturer["name"])
|
|
||||||
|
|
||||||
|
|
||||||
class FirmwareVersion:
|
|
||||||
def __init__(self, search: bool, current: str, update: bool, running: bool):
|
|
||||||
self.search: bool = search
|
|
||||||
self.current: str = current
|
|
||||||
self.update: bool = update
|
|
||||||
self.running: bool = running
|
|
||||||
|
|
||||||
def __repr__(self):
|
|
||||||
return f"search: {self.search}; current: {self.current}; update: {self.update}; running: {self.running}"
|
|
||||||
|
|
||||||
def to_json(self):
|
|
||||||
return {"search": self.search, "current": self.current, "update": self.update, "running": self.running}
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def parse_dict(firmware_version: dict):
|
|
||||||
return FirmwareVersion(firmware_version["search"],
|
|
||||||
firmware_version["current"],
|
|
||||||
firmware_version["update"],
|
|
||||||
firmware_version["running"])
|
|
||||||
|
|
||||||
|
|
||||||
class PushService:
|
|
||||||
def __init__(self, mail_address: str, unit_settings: List, is_enabled: bool):
|
|
||||||
self.mail_address: str = mail_address
|
|
||||||
self.unit_settings: List = unit_settings
|
|
||||||
self.is_enabled: bool = is_enabled
|
|
||||||
|
|
||||||
def __repr__(self):
|
|
||||||
return f"<PushService MailAddress: {self.mail_address}; UnitSettings: {self.unit_settings}; " \
|
|
||||||
f"isEnabled: {self.is_enabled}>"
|
|
||||||
|
|
||||||
def to_json(self):
|
|
||||||
return {"mailAddress": self.mail_address, "unitSettings": self.unit_settings, "isEnabled": self.is_enabled}
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def parse_dict(push_service: Dict):
|
|
||||||
return PushService(push_service["mailAddress"], push_service["unitSettings"], push_service["isEnabled"])
|
|
||||||
|
|
||||||
|
|
||||||
class SkillType(Enum):
|
|
||||||
SmartHomeTemperatureSensor = auto()
|
|
||||||
SmartHomeThermostat = auto()
|
|
||||||
SmartHomeBattery = auto()
|
|
||||||
|
|
||||||
|
|
||||||
class Skill(ABC):
|
|
||||||
def __init__(self, skill_type: SkillType):
|
|
||||||
self.type: SkillType = skill_type
|
|
||||||
|
|
||||||
@abstractmethod
|
|
||||||
def to_json(self):
|
|
||||||
pass
|
|
||||||
|
|
||||||
@abstractmethod
|
|
||||||
def to_web_data(self, device_id: int):
|
|
||||||
pass
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def parse_dict(skill: Dict):
|
|
||||||
skill_type = SkillType[skill["type"]]
|
|
||||||
if skill_type == SkillType.SmartHomeTemperatureSensor:
|
|
||||||
return TemperatureSkill.parse_dict(skill)
|
|
||||||
elif skill_type == SkillType.SmartHomeThermostat:
|
|
||||||
return ThermostatSkill.parse_dict(skill)
|
|
||||||
elif skill_type == SkillType.SmartHomeBattery:
|
|
||||||
return BatterySkill.parse_dict(skill)
|
|
||||||
else:
|
else:
|
||||||
raise NotImplementedError(skill_type)
|
self.id = -1
|
||||||
|
name_value = state.get("displayName")
|
||||||
|
if isinstance(name_value, str):
|
||||||
|
self.display_name = name_value
|
||||||
|
else:
|
||||||
|
self.display_name = ""
|
||||||
|
self.temp_sensor = TemperatureSensor(
|
||||||
|
current_celsius=self._get_temp_value("currentInCelsius"),
|
||||||
|
offset=self._get_temp_value("offset"),
|
||||||
|
)
|
||||||
|
|
||||||
|
def to_web_data(self) -> Dict[str, Any]:
|
||||||
class PresetName(Enum):
|
data: Dict[str, Any] = {
|
||||||
LOWER_TEMPERATURE = auto()
|
"device": self.id,
|
||||||
UPPER_TEMPERATURE = auto()
|
"ule_device_name": self.display_name,
|
||||||
HOLIDAY_TEMPERATURE = auto()
|
|
||||||
|
|
||||||
|
|
||||||
class Preset:
|
|
||||||
def __init__(self, name: PresetName, temperature: Optional[int]):
|
|
||||||
self.name: PresetName = name
|
|
||||||
self.temperature: Optional[int] = temperature
|
|
||||||
|
|
||||||
def __repr__(self):
|
|
||||||
return f"<Preset name: {self.name.name}; temperature: {self.temperature}>"
|
|
||||||
|
|
||||||
def to_json(self):
|
|
||||||
return {"name": self.name.name, "temperature": self.temperature}
|
|
||||||
|
|
||||||
|
|
||||||
class Description:
|
|
||||||
def __init__(self, action: str, preset_temperature: Optional[Preset]):
|
|
||||||
self.action: str = action
|
|
||||||
self.preset_temperature: Optional[Preset] = preset_temperature
|
|
||||||
|
|
||||||
def __repr__(self):
|
|
||||||
desc = f"<Description action: {self.action}"
|
|
||||||
if self.preset_temperature is not None:
|
|
||||||
desc += f"; presetTemperaturet: {self.preset_temperature}"
|
|
||||||
desc += " >"
|
|
||||||
|
|
||||||
return desc
|
|
||||||
|
|
||||||
def to_json(self):
|
|
||||||
if self.preset_temperature is not None:
|
|
||||||
return {"action": self.action, "presetTemperature": self.preset_temperature.to_json()}
|
|
||||||
return {"action": self.action}
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def parse_dict(description: Dict):
|
|
||||||
preset: Optional[Preset] = None
|
|
||||||
if "presetTemperature" in description.keys():
|
|
||||||
preset = Preset(PresetName[description["presetTemperature"]["name"]],
|
|
||||||
description["presetTemperature"]["temperature"] if "temperature" in description["presetTemperature"] else None)
|
|
||||||
return Description(description["action"], preset)
|
|
||||||
|
|
||||||
|
|
||||||
class Repetition(Enum):
|
|
||||||
NONE = auto()
|
|
||||||
YEARLY = auto()
|
|
||||||
|
|
||||||
|
|
||||||
class TimeSetting:
|
|
||||||
def __init__(self, start_date: Optional[str], start_time: Optional[str],
|
|
||||||
end_date: Optional[str] = None, end_time: Optional[str] = None,
|
|
||||||
repetition: Repetition = Repetition.NONE, day_of_week: Optional[WeekDay] = None):
|
|
||||||
self.start_date: Optional[str] = start_date
|
|
||||||
self.start_time: Optional[str] = start_time
|
|
||||||
self.end_date: Optional[str] = end_date
|
|
||||||
self.end_time: Optional[str] = end_time
|
|
||||||
self.repetition: Repetition = repetition
|
|
||||||
self.day_of_week: Optional[WeekDay] = day_of_week
|
|
||||||
|
|
||||||
def __repr__(self):
|
|
||||||
desc = f"<TimeSettings "
|
|
||||||
if self.day_of_week is not None:
|
|
||||||
desc += f"dayOfWeek: {self.day_of_week.name}; "
|
|
||||||
if self.start_date is not None:
|
|
||||||
desc += f"startDate: {self.start_date} "
|
|
||||||
if self.start_time is not None:
|
|
||||||
desc += f"startTime: {self.start_time} "
|
|
||||||
if self.end_date is not None:
|
|
||||||
desc += f"endDate: {self.end_date} "
|
|
||||||
if self.end_time is not None:
|
|
||||||
desc += f"endTime: {self.end_time} "
|
|
||||||
if self.repetition != Repetition.NONE:
|
|
||||||
desc += f"repetition: {self.repetition.name} "
|
|
||||||
desc += ">"
|
|
||||||
|
|
||||||
return desc
|
|
||||||
|
|
||||||
def to_json(self): # ToDo: Return typehints for all to_json
|
|
||||||
state = {}
|
|
||||||
if self.start_date is not None:
|
|
||||||
state["startDate"] = self.start_date
|
|
||||||
if self.start_time is not None:
|
|
||||||
state["startTime"] = self.start_time
|
|
||||||
if self.end_date is not None:
|
|
||||||
state["endDate"] = self.end_date
|
|
||||||
if self.end_time is not None:
|
|
||||||
state["endTime"] = self.end_time
|
|
||||||
if self.repetition != Repetition.NONE:
|
|
||||||
state["repetition"] = self.repetition.name
|
|
||||||
if self.day_of_week is not None:
|
|
||||||
state = {"dayOfWeek": self.day_of_week.name,
|
|
||||||
"time": state}
|
|
||||||
|
|
||||||
return state
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def parse_dict(time_setting: Dict):
|
|
||||||
start_date: Optional[str] = None
|
|
||||||
start_time: Optional[str] = None
|
|
||||||
end_date: Optional[str] = None
|
|
||||||
end_time: Optional[str] = None
|
|
||||||
day_of_week: Optional[WeekDay] = None
|
|
||||||
repetition: Repetition = Repetition.NONE
|
|
||||||
if "dayOfWeek" in time_setting.keys():
|
|
||||||
day_of_week = WeekDay[time_setting["dayOfWeek"]]
|
|
||||||
time_setting = time_setting["time"]
|
|
||||||
if "startDate" in time_setting.keys():
|
|
||||||
start_date = time_setting["startDate"]
|
|
||||||
if "startTime" in time_setting.keys():
|
|
||||||
start_time = time_setting["startTime"]
|
|
||||||
if "endDate" in time_setting.keys():
|
|
||||||
end_date = time_setting["endDate"]
|
|
||||||
if "endTime" in time_setting.keys():
|
|
||||||
end_time = time_setting["endTime"]
|
|
||||||
if "repetition" in time_setting.keys():
|
|
||||||
repetition = Repetition[time_setting["repetition"]]
|
|
||||||
|
|
||||||
return TimeSetting(start_date, start_time, end_date, end_time, repetition, day_of_week)
|
|
||||||
|
|
||||||
|
|
||||||
class Change:
|
|
||||||
def __init__(self, description: Description, time_setting: TimeSetting):
|
|
||||||
self.description: Description = description
|
|
||||||
self.time_setting: TimeSetting = time_setting
|
|
||||||
|
|
||||||
def __repr__(self):
|
|
||||||
return f"<Change description: {self.description}; timeSetting: {self.time_setting}>"
|
|
||||||
|
|
||||||
def to_json(self):
|
|
||||||
return {"description": self.description.to_json(), "timeSetting": self.time_setting.to_json()}
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def parse_dict(change: Dict):
|
|
||||||
return Change(Description.parse_dict(change["description"]), TimeSetting.parse_dict(change["timeSetting"]))
|
|
||||||
|
|
||||||
|
|
||||||
class TemperatureDropDetection:
|
|
||||||
def __init__(self, do_not_heat_offset_in_minutes: int, sensitivity: int):
|
|
||||||
self.do_not_heat_offset_in_minutes: int = do_not_heat_offset_in_minutes
|
|
||||||
self.sensitivity: int = sensitivity
|
|
||||||
|
|
||||||
def __repr__(self):
|
|
||||||
# ToDo
|
|
||||||
pass
|
|
||||||
|
|
||||||
def to_json(self):
|
|
||||||
return {
|
|
||||||
"doNotHeatOffsetInMinutes": self.do_not_heat_offset_in_minutes,
|
|
||||||
"sensitivity": self.sensitivity
|
|
||||||
}
|
}
|
||||||
|
|
||||||
def to_web_data(self, device_id: int):
|
units = self._get_units()
|
||||||
return {
|
for unit in units:
|
||||||
"WindowOpenTrigger": self.sensitivity + 3,
|
data.update(self._unit_to_web_data(unit))
|
||||||
"WindowOpenTimer": self.do_not_heat_offset_in_minutes
|
return data
|
||||||
}
|
|
||||||
|
|
||||||
@staticmethod
|
def set_offset(self, offset: float) -> None:
|
||||||
def parse_dict(drop_detection: Dict):
|
self.temp_sensor.offset = offset
|
||||||
return TemperatureDropDetection(drop_detection["doNotHeatOffsetInMinutes"],
|
self._set_temp_value("offset", offset)
|
||||||
drop_detection["sensitivity"])
|
|
||||||
|
|
||||||
|
def get_offset(self) -> Optional[float]:
|
||||||
|
return self.temp_sensor.offset
|
||||||
|
|
||||||
class ScheduleKind(Enum):
|
def get_temperature(self) -> Optional[float]:
|
||||||
REPETITIVE = auto()
|
return self.temp_sensor.current_celsius
|
||||||
WEEKLY_TIMETABLE = auto()
|
|
||||||
|
|
||||||
|
def _get_temp_value(self, key: str) -> Optional[float]:
|
||||||
|
sensor_skill = self._find_device_temp_skill()
|
||||||
|
if sensor_skill is None:
|
||||||
|
return None
|
||||||
|
value = sensor_skill.get(key)
|
||||||
|
if isinstance(value, (int, float)):
|
||||||
|
return float(value)
|
||||||
|
return None
|
||||||
|
|
||||||
class ThermostatSkillMode(Enum):
|
def _set_temp_value(self, key: str, value: float) -> None:
|
||||||
TARGET_TEMPERATURE = auto()
|
sensor_skill = self._find_device_temp_skill()
|
||||||
|
if sensor_skill is None:
|
||||||
|
return
|
||||||
|
sensor_skill[key] = value
|
||||||
|
|
||||||
|
def _find_device_temp_skill(self) -> Optional[Dict[str, Any]]:
|
||||||
|
for unit in self._as_dict_list(self.raw_state.get("units")):
|
||||||
|
if unit.get("type") != "TEMPERATURE_SENSOR":
|
||||||
|
continue
|
||||||
|
if unit.get("id") != self.id:
|
||||||
|
continue
|
||||||
|
for skill in self._as_dict_list(unit.get("skills")):
|
||||||
|
if skill.get("type") == "SmartHomeTemperatureSensor":
|
||||||
|
return skill
|
||||||
|
return None
|
||||||
|
|
||||||
class Action:
|
def _get_units(self) -> List[Dict[str, Any]]:
|
||||||
def __init__(self, is_enabled: bool, time_setting: TimeSetting, description: Description):
|
return self._as_dict_list(self.raw_state.get("units"))
|
||||||
self.is_enabled: bool = is_enabled
|
|
||||||
self.time_setting: TimeSetting = time_setting
|
|
||||||
self.description: Description = description
|
|
||||||
|
|
||||||
def __repr__(self):
|
def _unit_to_web_data(self, unit: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
return f"<Action isEnabled: {self.is_enabled}; timeSetting: {self.time_setting}; " \
|
data: Dict[str, Any] = {}
|
||||||
f"description: {self.description} >"
|
for skill in self._as_dict_list(unit.get("skills")):
|
||||||
|
skill_type = skill.get("type")
|
||||||
|
if skill_type == "SmartHomeThermostat":
|
||||||
|
data.update(self._thermostat_to_web_data(skill))
|
||||||
|
elif skill_type == "SmartHomeTemperatureSensor":
|
||||||
|
data.update(self._temperature_to_web_data(skill))
|
||||||
|
return data
|
||||||
|
|
||||||
def to_json(self):
|
def _temperature_to_web_data(self, skill: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
return {"isEnabled": self.is_enabled,
|
data: Dict[str, Any] = {}
|
||||||
"timeSetting": self.time_setting.to_json(),
|
current = skill.get("currentInCelsius")
|
||||||
"description": self.description.to_json()}
|
offset = skill.get("offset")
|
||||||
|
if current is not None:
|
||||||
|
data["Roomtemp"] = current
|
||||||
|
if offset is not None:
|
||||||
|
data["Offset"] = offset
|
||||||
|
return data
|
||||||
|
|
||||||
@staticmethod
|
def _thermostat_to_web_data(self, skill: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
def parse_dict(action: Dict):
|
data: Dict[str, Any] = {}
|
||||||
return Action(action["isEnabled"], TimeSetting.parse_dict(action["timeSetting"]),
|
|
||||||
Description.parse_dict(action["description"]))
|
|
||||||
|
|
||||||
|
upper = 0.0
|
||||||
|
lower = 0.0
|
||||||
|
for preset in self._as_dict_list(skill.get("presets")):
|
||||||
|
if preset.get("name") == "UPPER_TEMPERATURE":
|
||||||
|
upper = self._as_float(preset.get("temperature"), upper)
|
||||||
|
elif preset.get("name") == "LOWER_TEMPERATURE":
|
||||||
|
lower = self._as_float(preset.get("temperature"), lower)
|
||||||
|
data.update({"Heiztemp": upper, "Absenktemp": lower})
|
||||||
|
|
||||||
class Schedule:
|
used_temp_sensor = self._as_dict(skill.get("usedTempSensor"))
|
||||||
def __init__(self, is_enabled: bool, kind: ScheduleKind, name: str, actions: List[Action]):
|
if used_temp_sensor is not None:
|
||||||
self.is_enabled: bool = is_enabled
|
used_id = used_temp_sensor.get("id")
|
||||||
self.kind: ScheduleKind = kind
|
if used_id == self.id:
|
||||||
self.name: str = name
|
data.update({"ExtTempsensorID": "tochoose", "tempsensor": "own"})
|
||||||
self.actions: List[Action] = actions
|
else:
|
||||||
|
data.update({"ExtTempsensorID": used_id, "tempsensor": "extern"})
|
||||||
|
|
||||||
def __repr__(self):
|
time_control = self._as_dict(skill.get("timeControl"))
|
||||||
return f"<Schedule isEnabled: {self.is_enabled}; kind: {self.kind.name}; " \
|
if time_control is not None:
|
||||||
f"name: {self.name}; actions: {self.actions}>"
|
data.update(self._time_control_to_web_data(time_control))
|
||||||
|
|
||||||
def to_json(self):
|
drop_detection = self._as_dict(skill.get("temperatureDropDetection"))
|
||||||
return {
|
if drop_detection is not None:
|
||||||
"isEnabled": self.is_enabled,
|
data.update(self._drop_detection_to_web_data(drop_detection))
|
||||||
"kind": self.kind.name,
|
|
||||||
"name": self.name,
|
|
||||||
"actions": [action.to_json() for action in self.actions]
|
|
||||||
}
|
|
||||||
|
|
||||||
def to_web_data(self, device_id: int):
|
return data
|
||||||
data = {}
|
|
||||||
if self.kind == ScheduleKind.REPETITIVE and self.name == "HOLIDAYS": # ToDo: Enum for names?
|
def _drop_detection_to_web_data(self, drop: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
sensitivity = drop.get("sensitivity")
|
||||||
|
do_not_heat = drop.get("doNotHeatOffsetInMinutes")
|
||||||
|
data: Dict[str, Any] = {}
|
||||||
|
if sensitivity is not None:
|
||||||
|
data["WindowOpenTrigger"] = sensitivity + 3
|
||||||
|
if do_not_heat is not None:
|
||||||
|
data["WindowOpenTimer"] = do_not_heat
|
||||||
|
return data
|
||||||
|
|
||||||
|
def _time_control_to_web_data(self, time_control: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
data: Dict[str, Any] = {}
|
||||||
|
for schedule in self._as_dict_list(time_control.get("timeSchedules")):
|
||||||
|
data.update(self._schedule_to_web_data(schedule))
|
||||||
|
return data
|
||||||
|
|
||||||
|
def _schedule_to_web_data(self, schedule: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
data: Dict[str, Any] = {}
|
||||||
|
kind = schedule.get("kind")
|
||||||
|
name = schedule.get("name")
|
||||||
|
actions = self._as_dict_list(schedule.get("actions"))
|
||||||
|
|
||||||
|
if kind == "REPETITIVE" and name == "HOLIDAYS":
|
||||||
enabled_count = 0
|
enabled_count = 0
|
||||||
for num, holiday in enumerate(self.actions):
|
for num, holiday in enumerate(actions, start=1):
|
||||||
num += 1
|
time_setting = self._as_dict(holiday.get("timeSetting"))
|
||||||
_, start_month, start_day = holiday.time_setting.start_date.split("-")
|
if time_setting is None:
|
||||||
_, end_month, end_day = holiday.time_setting.end_date.split("-")
|
continue
|
||||||
data.update({
|
start_date = self._as_str(time_setting.get("startDate"), "0000-00-00")
|
||||||
|
end_date = self._as_str(time_setting.get("endDate"), "0000-00-00")
|
||||||
|
start_time = self._as_str(time_setting.get("startTime"), "00:00:00")
|
||||||
|
end_time = self._as_str(time_setting.get("endTime"), "00:00:00")
|
||||||
|
_, start_month, start_day = start_date.split("-")
|
||||||
|
_, end_month, end_day = end_date.split("-")
|
||||||
|
data.update(
|
||||||
|
{
|
||||||
f"Holiday{num}StartDay": start_day,
|
f"Holiday{num}StartDay": start_day,
|
||||||
f"Holiday{num}StartMonth": start_month,
|
f"Holiday{num}StartMonth": start_month,
|
||||||
f"Holiday{num}StartHour": holiday.time_setting.start_time.split(":")[0],
|
f"Holiday{num}StartHour": start_time.split(":")[0],
|
||||||
f"Holiday{num}EndDay": end_day,
|
f"Holiday{num}EndDay": end_day,
|
||||||
f"Holiday{num}EndMonth": end_month,
|
f"Holiday{num}EndMonth": end_month,
|
||||||
f"Holiday{num}EndHour": holiday.time_setting.end_time.split(":")[0],
|
f"Holiday{num}EndHour": end_time.split(":")[0],
|
||||||
f"Holiday{num}Enabled": 1 if holiday.is_enabled else 0,
|
f"Holiday{num}Enabled": 1 if holiday.get("isEnabled") else 0,
|
||||||
f"Holiday{num}ID": num
|
f"Holiday{num}ID": num,
|
||||||
})
|
}
|
||||||
if holiday.is_enabled:
|
)
|
||||||
|
if holiday.get("isEnabled"):
|
||||||
enabled_count += 1
|
enabled_count += 1
|
||||||
data["HolidayEnabledCount"] = enabled_count
|
data["HolidayEnabledCount"] = enabled_count
|
||||||
elif self.kind == ScheduleKind.REPETITIVE and self.name == "SUMMER_TIME":
|
elif kind == "REPETITIVE" and name == "SUMMER_TIME" and actions:
|
||||||
_, start_month, start_day = self.actions[0].time_setting.start_date.split("-")
|
action = actions[0]
|
||||||
_, end_month, end_day = self.actions[0].time_setting.end_date.split("-")
|
time_setting = self._as_dict(action.get("timeSetting"))
|
||||||
|
if time_setting is not None:
|
||||||
|
start_date = self._as_str(time_setting.get("startDate"), "0000-00-00")
|
||||||
|
end_date = self._as_str(time_setting.get("endDate"), "0000-00-00")
|
||||||
|
_, start_month, start_day = start_date.split("-")
|
||||||
|
_, end_month, end_day = end_date.split("-")
|
||||||
data = {
|
data = {
|
||||||
"SummerStartDay": start_day,
|
"SummerStartDay": start_day,
|
||||||
"SummerStartMonth": start_month,
|
"SummerStartMonth": start_month,
|
||||||
"SummerEndDay": end_day,
|
"SummerEndDay": end_day,
|
||||||
"SummerEndMonth": end_month,
|
"SummerEndMonth": end_month,
|
||||||
"SummerEnabled": 1 if self.is_enabled else 0
|
"SummerEnabled": 1 if schedule.get("isEnabled") else 0,
|
||||||
}
|
}
|
||||||
elif self.kind == ScheduleKind.WEEKLY_TIMETABLE and self.name == "TEMPERATURE":
|
elif kind == "WEEKLY_TIMETABLE" and name == "TEMPERATURE":
|
||||||
timer_items = {}
|
timer_items: Dict[str, List[int]] = {}
|
||||||
for action in self.actions:
|
for action in actions:
|
||||||
if not action.is_enabled:
|
if not action.get("isEnabled"):
|
||||||
continue
|
continue
|
||||||
time = "".join(action.time_setting.start_time.split(":")[:-1])
|
time_setting = self._as_dict(action.get("timeSetting"))
|
||||||
if time not in timer_items.keys():
|
if time_setting is None:
|
||||||
|
continue
|
||||||
|
start_time = self._as_str(time_setting.get("startTime"), "")
|
||||||
|
if not start_time:
|
||||||
|
continue
|
||||||
|
time = "".join(start_time.split(":")[:-1])
|
||||||
|
if time not in timer_items:
|
||||||
timer_items[time] = [0, 0]
|
timer_items[time] = [0, 0]
|
||||||
heat = 1 if action.description.preset_temperature.name == PresetName.UPPER_TEMPERATURE else 0
|
description = self._as_dict(action.get("description"))
|
||||||
days = timer_items[time][heat]
|
preset_temperature = None
|
||||||
days |= action.time_setting.day_of_week
|
if description is not None:
|
||||||
timer_items[time][heat] = days
|
preset_temperature = self._as_dict(
|
||||||
num = 0
|
description.get("presetTemperature")
|
||||||
for key in timer_items.keys():
|
|
||||||
if timer_items[key][0] != 0:
|
|
||||||
data.update({f"timer_item_{num}": f"{key};0;{timer_items[key][0].as_integer_ratio()[0]}"})
|
|
||||||
num += 1
|
|
||||||
if timer_items[key][1] != 0:
|
|
||||||
data.update({f"timer_item_{num}": f"{key};1;{timer_items[key][1].as_integer_ratio()[0]}"})
|
|
||||||
num += 1
|
|
||||||
else:
|
|
||||||
raise NotImplementedError(self.name)
|
|
||||||
|
|
||||||
return data
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def parse_dict(schedule: Dict): # ToDo: Make TypedDicts for all those dicts
|
|
||||||
return Schedule(schedule["isEnabled"],
|
|
||||||
ScheduleKind[schedule["kind"]],
|
|
||||||
schedule["name"],
|
|
||||||
[Action.parse_dict(action) for action in schedule["actions"]])
|
|
||||||
|
|
||||||
|
|
||||||
class TimeControl:
|
|
||||||
def __init__(self, is_enabled: bool, time_schedules: List[Schedule]):
|
|
||||||
self.is_enabled: bool = is_enabled
|
|
||||||
self.time_schedules: List[Schedule] = time_schedules
|
|
||||||
|
|
||||||
def __repr__(self):
|
|
||||||
return f"<TimeControl isEnabled: {self.is_enabled}; timeSchedules: {self.time_schedules}"
|
|
||||||
|
|
||||||
def to_json(self):
|
|
||||||
return {
|
|
||||||
"isEnabled": self.is_enabled,
|
|
||||||
"timeSchedules": [schedule.to_json() for schedule in self.time_schedules]
|
|
||||||
}
|
|
||||||
|
|
||||||
def to_web_data(self, device_id: int):
|
|
||||||
data = {}
|
|
||||||
for schedule in self.time_schedules:
|
|
||||||
data.update(schedule.to_web_data(device_id))
|
|
||||||
return data
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def parse_dict(time_control: Dict):
|
|
||||||
return TimeControl(time_control["isEnabled"],
|
|
||||||
[Schedule.parse_dict(schedule) for schedule in time_control["timeSchedules"]])
|
|
||||||
|
|
||||||
|
|
||||||
class ThermostatSkill(Skill):
|
|
||||||
def __init__(self, presets: List[Preset], next_change: Optional[Change],
|
|
||||||
temperature_drop_detection: TemperatureDropDetection,
|
|
||||||
target_temp: int, time_control: TimeControl,
|
|
||||||
mode: ThermostatSkillMode, used_temp_sensor: Unit):
|
|
||||||
super().__init__(SkillType.SmartHomeThermostat)
|
|
||||||
self.presets: List[Preset] = presets
|
|
||||||
self.next_change: Optional[Change] = next_change
|
|
||||||
self.temperature_drop_detection: TemperatureDropDetection = temperature_drop_detection
|
|
||||||
self.target_temp: int = target_temp
|
|
||||||
self.time_control: TimeControl = time_control
|
|
||||||
self.mode: ThermostatSkillMode = mode
|
|
||||||
self.used_temp_sensor: Unit = used_temp_sensor
|
|
||||||
|
|
||||||
def __repr__(self):
|
|
||||||
# ToDo
|
|
||||||
pass
|
|
||||||
|
|
||||||
def to_web_data(self, device_id: int):
|
|
||||||
upper = 0.0
|
|
||||||
lower = 0.0
|
|
||||||
for preset in self.presets:
|
|
||||||
if preset.name == PresetName.UPPER_TEMPERATURE:
|
|
||||||
upper = preset.temperature
|
|
||||||
elif preset.name == PresetName.LOWER_TEMPERATURE:
|
|
||||||
lower = preset.temperature
|
|
||||||
|
|
||||||
data = {
|
|
||||||
"Heiztemp": upper,
|
|
||||||
"Absenktemp": lower
|
|
||||||
}
|
|
||||||
|
|
||||||
if device_id == self.used_temp_sensor.id:
|
|
||||||
data.update({"ExtTempsensorID": "tochoose", "tempsensor": "own"})
|
|
||||||
else:
|
|
||||||
data.update({"ExtTempsensorID": self.used_temp_sensor.id, "tempsensor": "extern"})
|
|
||||||
|
|
||||||
data.update(self.time_control.to_web_data(device_id))
|
|
||||||
|
|
||||||
data.update(self.temperature_drop_detection.to_web_data(device_id))
|
|
||||||
|
|
||||||
return data
|
|
||||||
|
|
||||||
def to_json(self):
|
|
||||||
state = {
|
|
||||||
"type": self.type.name,
|
|
||||||
"presets": [preset.to_json() for preset in self.presets],
|
|
||||||
"temperatureDropDetection": self.temperature_drop_detection.to_json(),
|
|
||||||
"targetTemp": self.target_temp,
|
|
||||||
"timeControl": self.time_control.to_json(),
|
|
||||||
"mode": self.mode.name,
|
|
||||||
"usedTempSensor": self.used_temp_sensor.to_json()
|
|
||||||
}
|
|
||||||
if self.next_change is not None:
|
|
||||||
state["nextChange"] = self.next_change.to_json()
|
|
||||||
|
|
||||||
return state
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def parse_dict(skill: Dict):
|
|
||||||
return ThermostatSkill(
|
|
||||||
[Preset(PresetName[preset["name"]], preset["temperature"]) for preset in skill["presets"]],
|
|
||||||
Change.parse_dict(skill["nextChange"]) if "nextChange" in skill.keys() else None,
|
|
||||||
TemperatureDropDetection.parse_dict(skill["temperatureDropDetection"]),
|
|
||||||
skill["targetTemp"],
|
|
||||||
TimeControl.parse_dict(skill["timeControl"]),
|
|
||||||
ThermostatSkillMode[skill["mode"]],
|
|
||||||
Unit.parse_dict(skill["usedTempSensor"], None)
|
|
||||||
)
|
)
|
||||||
|
heat = 0
|
||||||
|
if (
|
||||||
class TemperatureSkill(Skill):
|
preset_temperature is not None
|
||||||
def __init__(self, offset: float, current_in_celsius: int):
|
and preset_temperature.get("name") == "UPPER_TEMPERATURE"
|
||||||
super().__init__(SkillType.SmartHomeTemperatureSensor)
|
):
|
||||||
self.offset: float = offset
|
heat = 1
|
||||||
self.current_in_celsius: int = current_in_celsius
|
day_of_week = self._as_int(time_setting.get("dayOfWeek"), 0)
|
||||||
|
timer_items[time][heat] |= day_of_week
|
||||||
def __repr__(self):
|
num = 0
|
||||||
return f"{self.type.name}: " \
|
for key, value in timer_items.items():
|
||||||
f"offset: {self.offset}; currentInCelsius: {self.current_in_celsius}"
|
if value[0] != 0:
|
||||||
|
data.update({f"timer_item_{num}": f"{key};0;{value[0]}"})
|
||||||
def to_json(self):
|
num += 1
|
||||||
return {
|
if value[1] != 0:
|
||||||
"type": self.type.name,
|
data.update({f"timer_item_{num}": f"{key};1;{value[1]}"})
|
||||||
"offset": self.offset,
|
num += 1
|
||||||
"currentInCelsius": self.current_in_celsius
|
|
||||||
}
|
|
||||||
|
|
||||||
def to_web_data(self, device_id: int):
|
|
||||||
data = {
|
|
||||||
"Roomtemp": self.current_in_celsius,
|
|
||||||
"Offset": self.offset
|
|
||||||
}
|
|
||||||
return data
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def parse_dict(skill: Dict):
|
|
||||||
return TemperatureSkill(skill["offset"], skill["currentInCelsius"])
|
|
||||||
|
|
||||||
|
|
||||||
class BatterySkill(Skill):
|
|
||||||
def __init__(self, charge_level_in_percent: int):
|
|
||||||
super().__init__(SkillType.SmartHomeBattery)
|
|
||||||
self.charge_level_in_percent: int = charge_level_in_percent
|
|
||||||
|
|
||||||
def __repr__(self):
|
|
||||||
return f"{self.type.name}: chargeLevelInPercent: {self.charge_level_in_percent}"
|
|
||||||
|
|
||||||
def to_json(self):
|
|
||||||
return {"type": self.type.name, "chargeLevelInPercent": self.charge_level_in_percent}
|
|
||||||
|
|
||||||
def to_web_data(self, device_id: int):
|
|
||||||
return {}
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def parse_dict(skill: Dict):
|
|
||||||
return BatterySkill(skill["chargeLevelInPercent"])
|
|
||||||
|
|
||||||
|
|
||||||
class UnitTypes(Enum):
|
|
||||||
BATTERY = auto()
|
|
||||||
TEMPERATURE_SENSOR = auto()
|
|
||||||
THERMOSTAT = auto()
|
|
||||||
|
|
||||||
|
|
||||||
class Unit:
|
|
||||||
def __init__(self, unit_type: UnitTypes, idx: int, display_name: str,
|
|
||||||
device: Optional[Device], skills: List[Union[Dict, Skill]],
|
|
||||||
interaction_controls: Optional[List] = None):
|
|
||||||
self.type: UnitTypes = unit_type
|
|
||||||
self.id: int = idx
|
|
||||||
self.display_name: str = display_name
|
|
||||||
self.device: Optional[Device] = device
|
|
||||||
self.skills: List[Skill] = skills
|
|
||||||
self.interaction_controls: Optional[List] = interaction_controls # ToDo: Type for this
|
|
||||||
|
|
||||||
def __repr__(self):
|
|
||||||
desc = f"type: {self.type.name}; id: {self.id}; displayName: {self.display_name};"
|
|
||||||
if self.device is not None:
|
|
||||||
desc += f"device: {self.device.short_description()}; "
|
|
||||||
desc += f"skills: {self.skills}; "
|
|
||||||
if self.interaction_controls is not None:
|
|
||||||
desc += f"interactionControls: {self.interaction_controls} ;"
|
|
||||||
return desc
|
|
||||||
|
|
||||||
def to_json(self):
|
|
||||||
state = {
|
|
||||||
"type": self.type.name,
|
|
||||||
"id": self.id,
|
|
||||||
"displayName": self.display_name,
|
|
||||||
"skills": [skill.to_json() for skill in self.skills]
|
|
||||||
}
|
|
||||||
if self.device is not None:
|
|
||||||
state["device"] = self.device.to_short_json()
|
|
||||||
if self.interaction_controls is not None:
|
|
||||||
state["interactionControls"] = self.interaction_controls
|
|
||||||
|
|
||||||
return state
|
|
||||||
|
|
||||||
def to_web_data(self, device_id: int):
|
|
||||||
data = {}
|
|
||||||
for skill in self.skills:
|
|
||||||
data.update(skill.to_web_data(device_id))
|
|
||||||
|
|
||||||
return data
|
return data
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def parse_dict(unit: Dict, device: Optional[Device]):
|
def _as_dict_list(value: Any) -> List[Dict[str, Any]]:
|
||||||
return Unit(UnitTypes[unit["type"]],
|
result: List[Dict[str, Any]] = []
|
||||||
unit["id"],
|
if not isinstance(value, list):
|
||||||
unit["displayName"],
|
return result
|
||||||
device,
|
items = cast(List[Any], value)
|
||||||
[Skill.parse_dict(skill) for skill in unit["skills"]],
|
for item in items:
|
||||||
unit["interactionControls"] if "interactionControls" in unit.keys() else None)
|
if isinstance(item, dict):
|
||||||
|
result.append(cast(Dict[str, Any], item))
|
||||||
|
return result
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _as_dict(value: Any) -> Optional[Dict[str, Any]]:
|
||||||
|
if isinstance(value, dict):
|
||||||
|
return cast(Dict[str, Any], value)
|
||||||
|
return None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _as_str(value: Any, default: str) -> str:
|
||||||
|
if isinstance(value, str):
|
||||||
|
return value
|
||||||
|
return default
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _as_int(value: Any, default: int) -> int:
|
||||||
|
if isinstance(value, int):
|
||||||
|
return value
|
||||||
|
return default
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _as_float(value: Any, default: float) -> float:
|
||||||
|
if isinstance(value, (int, float)):
|
||||||
|
return float(value)
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
class DeviceType(Enum):
|
__all__ = ["Device", "TemperatureSensor"]
|
||||||
SmartHomeDevice = auto()
|
|
||||||
|
|
||||||
|
|
||||||
class DeviceCategory(Enum):
|
|
||||||
THERMOSTAT = auto()
|
|
||||||
|
|
||||||
|
|
||||||
class Device:
|
|
||||||
def __init__(self, state):
|
|
||||||
self.type: Optional[DeviceType] = None
|
|
||||||
self.is_deletable: bool = False
|
|
||||||
self.id: int = -1
|
|
||||||
self.master_connection_state = None # ToDo
|
|
||||||
self.display_name: Optional[str] = None
|
|
||||||
self.category: Optional[str] = None
|
|
||||||
self.units = None # ToDo
|
|
||||||
self.firmware_version: Optional[FirmwareVersion] = None
|
|
||||||
self.model: Optional[str] = None
|
|
||||||
self.is_editable: bool = False
|
|
||||||
self.manufacturer: Optional[Manufacturer] = None
|
|
||||||
self.push_service: Optional[PushService] = None
|
|
||||||
self.actor_identification_number: Optional[str] = None
|
|
||||||
|
|
||||||
if state is not None:
|
|
||||||
self.parse_state(state)
|
|
||||||
|
|
||||||
def __repr__(self):
|
|
||||||
# ToDo
|
|
||||||
pass
|
|
||||||
|
|
||||||
def short_description(self):
|
|
||||||
desc = f"masterConnectionState: {self.master_connection_state}; " \
|
|
||||||
f"type: {self.type}; model: {self.model}; id: {self.id}; " \
|
|
||||||
f"manufacturer: {self.manufacturer}; " \
|
|
||||||
f"actorIdentificationNumber: {self.actor_identification_number}; " \
|
|
||||||
f"displayName: {self.display_name}"
|
|
||||||
|
|
||||||
return desc
|
|
||||||
|
|
||||||
def parse_state(self, state):
|
|
||||||
self.type = DeviceType[state["type"]]
|
|
||||||
self.is_deletable = state["isDeletable"]
|
|
||||||
self.id = state["id"]
|
|
||||||
self.master_connection_state = state["masterConnectionState"]
|
|
||||||
self.display_name = state["displayName"]
|
|
||||||
self.category = state["category"]
|
|
||||||
self.units = [Unit.parse_dict(unit, self) for unit in state["units"]]
|
|
||||||
self.firmware_version = FirmwareVersion.parse_dict(state["firmwareVersion"])
|
|
||||||
self.model = state["model"]
|
|
||||||
self.is_editable = state["isEditable"]
|
|
||||||
self.manufacturer = Manufacturer.parse_dict(state["manufacturer"])
|
|
||||||
self.push_service = PushService.parse_dict(state["pushService"])
|
|
||||||
self.actor_identification_number = state["actorIdentificationNumber"]
|
|
||||||
|
|
||||||
def to_web_data(self):
|
|
||||||
data = {
|
|
||||||
"device": self.id,
|
|
||||||
"ule_device_name": self.display_name
|
|
||||||
}
|
|
||||||
for unit in self.units:
|
|
||||||
data.update(unit.to_web_data(self.id))
|
|
||||||
return data
|
|
||||||
|
|
||||||
def to_json(self):
|
|
||||||
state = {"type": self.type.name,
|
|
||||||
"isDeletable": self.is_deletable,
|
|
||||||
"id": self.id,
|
|
||||||
"masterConnectionState": self.master_connection_state,
|
|
||||||
"displayName": self.display_name,
|
|
||||||
"category": self.category,
|
|
||||||
"units": [unit.to_json() for unit in self.units],
|
|
||||||
"firmwareVersion": self.firmware_version.to_json(),
|
|
||||||
"model": self.model,
|
|
||||||
"isEditable": self.is_editable,
|
|
||||||
"manufacturer": self.manufacturer.to_json(),
|
|
||||||
"pushService": self.push_service.to_json(),
|
|
||||||
"actorIdentificationNumber": self.actor_identification_number}
|
|
||||||
|
|
||||||
return state
|
|
||||||
|
|
||||||
def set_offset(self, offset: float):
|
|
||||||
temp_sensor: Optional[Unit] = None
|
|
||||||
for unit in self.units:
|
|
||||||
if unit.type == UnitTypes.TEMPERATURE_SENSOR:
|
|
||||||
temp_sensor = unit
|
|
||||||
break
|
|
||||||
temp_skill: Optional[TemperatureSkill] = None
|
|
||||||
for skill in temp_sensor.skills:
|
|
||||||
if skill.type == SkillType.SmartHomeTemperatureSensor:
|
|
||||||
temp_skill = typing.cast(TemperatureSkill, skill)
|
|
||||||
break
|
|
||||||
temp_skill.offset = offset
|
|
||||||
|
|
||||||
def get_offset(self):
|
|
||||||
temp_sensor: Optional[Unit] = None
|
|
||||||
for unit in self.units:
|
|
||||||
if unit.type == UnitTypes.TEMPERATURE_SENSOR:
|
|
||||||
temp_sensor = unit
|
|
||||||
break
|
|
||||||
temp_skill: Optional[TemperatureSkill] = None
|
|
||||||
for skill in temp_sensor.skills:
|
|
||||||
if skill.type == SkillType.SmartHomeTemperatureSensor:
|
|
||||||
temp_skill = typing.cast(TemperatureSkill, skill)
|
|
||||||
break
|
|
||||||
return temp_skill.offset
|
|
||||||
|
|
||||||
def get_temperature(self):
|
|
||||||
temp_sensor: Optional[Unit] = None
|
|
||||||
for unit in self.units:
|
|
||||||
if unit.type == UnitTypes.TEMPERATURE_SENSOR:
|
|
||||||
temp_sensor = unit
|
|
||||||
break
|
|
||||||
temp_skill: Optional[TemperatureSkill] = None
|
|
||||||
for skill in temp_sensor.skills:
|
|
||||||
if skill.type == SkillType.SmartHomeTemperatureSensor:
|
|
||||||
temp_skill = typing.cast(TemperatureSkill, skill)
|
|
||||||
break
|
|
||||||
return temp_skill.current_in_celsius
|
|
||||||
|
|
||||||
def to_short_json(self):
|
|
||||||
return {
|
|
||||||
"masterConnectionState": self.master_connection_state,
|
|
||||||
"type": self.type.name,
|
|
||||||
"model": self.model,
|
|
||||||
"id": self.id,
|
|
||||||
"manufacturer": self.manufacturer.to_json(),
|
|
||||||
"actorIdentificationNumber": self.actor_identification_number,
|
|
||||||
"displayName": self.display_name
|
|
||||||
}
|
|
||||||
|
|||||||
10
fritz_temp_sync/errors.py
Normal file
10
fritz_temp_sync/errors.py
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
class ConfigError(ValueError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class HomeAssistantError(RuntimeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class FritzBoxError(RuntimeError):
|
||||||
|
pass
|
||||||
@ -8,81 +8,153 @@ import re
|
|||||||
import xml.etree.ElementTree as ET
|
import xml.etree.ElementTree as ET
|
||||||
from asyncio import Task
|
from asyncio import Task
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from typing import Optional, Dict, List
|
from typing import Any, Dict, List, Optional
|
||||||
|
from urllib.parse import urlparse
|
||||||
import requests
|
|
||||||
|
|
||||||
|
import httpx # type: ignore[import-not-found]
|
||||||
from device import Device
|
from device import Device
|
||||||
|
from errors import FritzBoxError
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class FritzBox:
|
class FritzBox:
|
||||||
def __init__(self, url: str, password: str, update_timeout: int, user: str = None, dry_run: bool = False) -> None:
|
def __init__(
|
||||||
|
self,
|
||||||
|
url: str,
|
||||||
|
password: str,
|
||||||
|
update_timeout: int,
|
||||||
|
user: Optional[str] = None,
|
||||||
|
dry_run: bool = False,
|
||||||
|
force_ipv4: bool = False,
|
||||||
|
request_timeout: int = 10,
|
||||||
|
request_retries: int = 2,
|
||||||
|
) -> 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: str = url
|
self.url: str = url
|
||||||
self.dry_run: bool = dry_run
|
self.dry_run: bool = dry_run
|
||||||
self.user: Optional[str] = user
|
self.user: Optional[str] = user
|
||||||
self.session: requests.Session = requests.Session()
|
if force_ipv4:
|
||||||
|
transport = httpx.AsyncHTTPTransport(local_address="0.0.0.0")
|
||||||
|
else:
|
||||||
|
transport = None
|
||||||
|
self.session: httpx.AsyncClient = httpx.AsyncClient(transport=transport)
|
||||||
self.password: str = password
|
self.password: str = password
|
||||||
self.sid: Optional[str] = None
|
self.sid: Optional[str] = None
|
||||||
self.update_timeout: int = update_timeout
|
self.update_timeout: int = update_timeout
|
||||||
self.update_time: Dict[str, datetime] = {}
|
self.update_time: Dict[str, datetime] = {}
|
||||||
self.hold_connection: Optional[Task] = None
|
self.hold_connection: Optional[Task[None]] = None
|
||||||
|
self.request_timeout: int = request_timeout
|
||||||
|
self.request_retries: int = request_retries
|
||||||
|
|
||||||
async def hold_connection_alive(self) -> None:
|
async def hold_connection_alive(self) -> None:
|
||||||
while True:
|
while True:
|
||||||
# Session automatically destroyed after 20m of inactivity
|
# Session automatically destroyed after 20m of inactivity
|
||||||
# according to the manual
|
# according to the manual
|
||||||
await asyncio.sleep(19 * 60)
|
await asyncio.sleep(19 * 60)
|
||||||
self.check_session()
|
await self.check_session()
|
||||||
|
|
||||||
|
async def _request(
|
||||||
|
self,
|
||||||
|
method: str,
|
||||||
|
url: str,
|
||||||
|
data: Optional[Dict[str, Any]] = None,
|
||||||
|
follow_redirects: bool = True,
|
||||||
|
) -> Optional[Any]:
|
||||||
|
for attempt in range(self.request_retries + 1):
|
||||||
|
try:
|
||||||
|
return await self.session.request(
|
||||||
|
method,
|
||||||
|
url,
|
||||||
|
data=data,
|
||||||
|
timeout=self.request_timeout,
|
||||||
|
follow_redirects=follow_redirects,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
if attempt >= self.request_retries:
|
||||||
|
logger.error("Request failed (%s %s): %s", method, url, exc)
|
||||||
|
return None
|
||||||
|
backoff = 2**attempt
|
||||||
|
logger.warning(
|
||||||
|
"Request failed (%s %s), retry in %ss: %s",
|
||||||
|
method,
|
||||||
|
url,
|
||||||
|
backoff,
|
||||||
|
exc,
|
||||||
|
)
|
||||||
|
await asyncio.sleep(backoff)
|
||||||
|
return None
|
||||||
|
|
||||||
def _calc_challenge_v2(self, challenge: str) -> str:
|
def _calc_challenge_v2(self, challenge: str) -> str:
|
||||||
|
|
||||||
logging.debug(f"Calculate v2 challenge: {challenge}")
|
logger.debug("Calculate v2 challenge")
|
||||||
chall_regex = re.compile(
|
chall_regex = re.compile(
|
||||||
r"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]+)")
|
r"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_match = chall_regex.match(challenge)
|
||||||
|
if not chall_match:
|
||||||
|
logger.error("Invalid Fritz!Box challenge format")
|
||||||
|
return ""
|
||||||
|
chall_parts = chall_match.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"])
|
||||||
salt2: bytes = bytes.fromhex(chall_parts["salt2"])
|
salt2: bytes = bytes.fromhex(chall_parts["salt2"])
|
||||||
iter2: int = int(chall_parts["iter2"])
|
iter2: int = int(chall_parts["iter2"])
|
||||||
|
|
||||||
hash1 = hashlib.pbkdf2_hmac('sha256', self.password.encode(), salt1, iter1)
|
hash1 = hashlib.pbkdf2_hmac("sha256", self.password.encode(), salt1, iter1)
|
||||||
response = salt2.hex() + "$" + hashlib.pbkdf2_hmac('sha256', hash1, salt2, iter2).hex()
|
response = (
|
||||||
|
salt2.hex() + "$" + hashlib.pbkdf2_hmac("sha256", hash1, salt2, iter2).hex()
|
||||||
|
)
|
||||||
|
|
||||||
return response
|
return response
|
||||||
|
|
||||||
def _calc_challenge_v1(self, challenge: str) -> str:
|
def _calc_challenge_v1(self, challenge: str) -> str:
|
||||||
""" Calculate the response for a challenge using legacy MD5 """
|
"""Calculate the response for a challenge using legacy MD5"""
|
||||||
logging.debug(f"Calculate v1 challenge: {challenge}")
|
logger.debug("Calculate v1 challenge")
|
||||||
response = f"{challenge}-{self.password}"
|
response = f"{challenge}-{self.password}"
|
||||||
response = response.encode("utf_16_le")
|
response_bytes = response.encode("utf_16_le")
|
||||||
response = challenge + "-" + hashlib.md5(response).hexdigest()
|
return challenge + "-" + hashlib.md5(response_bytes).hexdigest()
|
||||||
return response
|
|
||||||
|
|
||||||
def check_session(self) -> None:
|
async def check_session(self) -> None:
|
||||||
data = {
|
data: Dict[str, Any] = {
|
||||||
"xhr": 1,
|
"xhr": 1,
|
||||||
"sid": self.sid,
|
"sid": self.sid,
|
||||||
"lang": "de",
|
"lang": "de",
|
||||||
"page": "overview",
|
"page": "overview",
|
||||||
"xhrId": "first",
|
"xhrId": "first",
|
||||||
"noMenuRef": 1
|
"noMenuRef": 1,
|
||||||
}
|
}
|
||||||
r = self.session.post(f"{self.url}/{self._endpoints['data']}", data=data)
|
r = await self._request("POST", f"{self.url}/{self._endpoints['data']}", data)
|
||||||
|
if r is None:
|
||||||
|
return
|
||||||
if len(r.history) > 0:
|
if len(r.history) > 0:
|
||||||
if not self.login():
|
if not await self.login():
|
||||||
logging.error("Failed to login to Fritz!Box")
|
logger.error("Failed to login to Fritz!Box")
|
||||||
else:
|
else:
|
||||||
logging.debug("Already logged in")
|
logger.debug("Already logged in")
|
||||||
|
|
||||||
def login(self) -> bool:
|
async def login(self) -> bool:
|
||||||
logging.info(f"Login user {self.user} to Fritz!Box")
|
logger.info("Login user %s to Fritz!Box", self.user)
|
||||||
challenge = None
|
challenge = None
|
||||||
r = self.session.get(f"{self.url}/{self._endpoints['login']}")
|
login_url = f"{self.url}/{self._endpoints['login']}"
|
||||||
|
r = await self._request("GET", login_url, follow_redirects=False)
|
||||||
|
if r is None:
|
||||||
|
return False
|
||||||
|
if getattr(r, "status_code", 200) in {301, 302, 303, 307, 308}:
|
||||||
|
location = getattr(r, "headers", {}).get("Location")
|
||||||
|
if location:
|
||||||
|
retry_url, new_base = self._normalize_login_redirect(location)
|
||||||
|
logger.debug("Login redirect to %s, retry %s", location, retry_url)
|
||||||
|
r = await self._request("GET", retry_url, follow_redirects=False)
|
||||||
|
if r is None:
|
||||||
|
return False
|
||||||
|
if new_base:
|
||||||
|
self.url = new_base
|
||||||
xml = ET.fromstring(r.text)
|
xml = ET.fromstring(r.text)
|
||||||
for elem in xml:
|
for elem in xml:
|
||||||
if elem.tag == "SID":
|
if elem.tag == "SID":
|
||||||
@ -91,59 +163,70 @@ class FritzBox:
|
|||||||
challenge = elem.text
|
challenge = elem.text
|
||||||
elif self.user is None and elem.tag == "Users":
|
elif self.user is None and elem.tag == "Users":
|
||||||
for user_elem in elem:
|
for user_elem in elem:
|
||||||
if "fritz" in user_elem.text:
|
if user_elem.text and "fritz" in user_elem.text:
|
||||||
self.user = user_elem.text
|
self.user = user_elem.text
|
||||||
|
|
||||||
assert challenge is not None and self.user is not None
|
if challenge is None or self.user is None:
|
||||||
|
raise FritzBoxError("Missing Fritz!Box login challenge or user")
|
||||||
|
|
||||||
if challenge.startswith("2$"):
|
if challenge.startswith("2$"):
|
||||||
response = self._calc_challenge_v2(challenge)
|
response = self._calc_challenge_v2(challenge)
|
||||||
else:
|
else:
|
||||||
response = self._calc_challenge_v1(challenge)
|
response = self._calc_challenge_v1(challenge)
|
||||||
|
if not response:
|
||||||
|
raise FritzBoxError("Failed to compute Fritz!Box login response")
|
||||||
|
|
||||||
data = {
|
data = {"username": self.user, "response": response}
|
||||||
"username": self.user,
|
r = await self._request("POST", f"{self.url}/{self._endpoints['login']}", data)
|
||||||
"response": response
|
if r is None:
|
||||||
}
|
return False
|
||||||
|
logger.debug("Login response received")
|
||||||
r = self.session.post(f"{self.url}/{self._endpoints['login']}", data=data)
|
|
||||||
logging.debug(r.text)
|
|
||||||
xml = ET.fromstring(r.text)
|
xml = ET.fromstring(r.text)
|
||||||
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.info(f"Authenticated Fritz!Box: {len(self.sid) != self.sid.count('0')}")
|
sid = self.sid or ""
|
||||||
if len(self.sid) != self.sid.count("0"):
|
logger.info("Authenticated Fritz!Box: %s", len(sid) != sid.count("0"))
|
||||||
|
if len(sid) != sid.count("0"):
|
||||||
self.hold_connection = asyncio.create_task(self.hold_connection_alive())
|
self.hold_connection = asyncio.create_task(self.hold_connection_alive())
|
||||||
return len(self.sid) != self.sid.count("0")
|
return len(sid) != sid.count("0")
|
||||||
|
|
||||||
def logout(self) -> bool:
|
async def logout(self) -> bool:
|
||||||
logging.info("logout")
|
logger.info("Logout from Fritz!Box")
|
||||||
data = {
|
data: Dict[str, Any] = {
|
||||||
"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 = await self._request("POST", f"{self.url}/{self._endpoints['logout']}", data)
|
||||||
|
if r is None:
|
||||||
|
return False
|
||||||
if self.hold_connection is not None:
|
if self.hold_connection is not None:
|
||||||
self.hold_connection.cancel()
|
self.hold_connection.cancel()
|
||||||
|
|
||||||
return r.status_code == 200
|
return r.status_code == 200
|
||||||
|
|
||||||
def list_devices(self) -> Optional[List[Device]]:
|
async def list_devices(self) -> Optional[List[Device]]:
|
||||||
data = {
|
data: Dict[str, Any] = {
|
||||||
"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 = await self._request("POST", f"{self.url}/{self._endpoints['data']}", data)
|
||||||
logging.debug(r.text[:100])
|
if r is None:
|
||||||
|
return None
|
||||||
|
logger.debug("Devices response received")
|
||||||
if len(r.history) > 0:
|
if len(r.history) > 0:
|
||||||
if self.login():
|
if await self.login():
|
||||||
r = self.session.post(f"{self.url}/{self._endpoints['data']}", data=data)
|
r = await self._request(
|
||||||
|
"POST", f"{self.url}/{self._endpoints['data']}", data
|
||||||
|
)
|
||||||
|
if r is None:
|
||||||
|
return None
|
||||||
else:
|
else:
|
||||||
return None
|
return None
|
||||||
devices: List[Device] = []
|
devices: List[Device] = []
|
||||||
@ -152,36 +235,40 @@ class FritzBox:
|
|||||||
|
|
||||||
return devices
|
return devices
|
||||||
|
|
||||||
def get_device_data(self, idx: int = None, name: str = None) -> Optional[Device]:
|
async def get_device_data(
|
||||||
|
self, idx: Optional[int] = None, name: Optional[str] = None
|
||||||
|
) -> Optional[Device]:
|
||||||
if idx is None and name is None:
|
if idx is None and name is None:
|
||||||
logging.debug("No id or name given")
|
logger.debug("No id or name given")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
devices = self.list_devices()
|
devices = await self.list_devices()
|
||||||
device = None
|
if devices is None:
|
||||||
for device in devices:
|
return None
|
||||||
if device.id == idx or device.display_name == name:
|
found_device: Optional[Device] = None
|
||||||
|
for candidate in devices:
|
||||||
|
if candidate.id == idx or candidate.display_name == name:
|
||||||
|
found_device = candidate
|
||||||
break
|
break
|
||||||
device = None
|
|
||||||
|
|
||||||
if device is None:
|
if found_device is None:
|
||||||
logging.debug(f"Device {idx} {name} not found")
|
logger.debug("Device not found for id=%s name=%s", idx, name)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
return device
|
return found_device
|
||||||
|
|
||||||
def set_offset(self, device: Device) -> None:
|
async def set_offset(self, device: Device) -> None:
|
||||||
if self.dry_run:
|
if self.dry_run:
|
||||||
logging.warning("No updates in dry-run-mode")
|
logger.warning("Dry-run enabled, skipping update")
|
||||||
return
|
return
|
||||||
data = {
|
data: Dict[str, Any] = {
|
||||||
"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",
|
||||||
}
|
}
|
||||||
self.session.post(f"{self.url}/{self._endpoints['data']}", data=data)
|
await self._request("POST", f"{self.url}/{self._endpoints['data']}", data)
|
||||||
|
|
||||||
data = {
|
data = {
|
||||||
"xhr": 1,
|
"xhr": 1,
|
||||||
@ -190,24 +277,63 @@ class FritzBox:
|
|||||||
"view": "",
|
"view": "",
|
||||||
"back_to_page": "sh_dev",
|
"back_to_page": "sh_dev",
|
||||||
"apply": "",
|
"apply": "",
|
||||||
"oldpage": "/net/home_auto_hkr_edit.lua"
|
"oldpage": "/net/home_auto_hkr_edit.lua",
|
||||||
}
|
}
|
||||||
data.update(device.to_web_data())
|
data.update(device.to_web_data())
|
||||||
|
|
||||||
self.session.post(f"{self.url}/{self._endpoints['data']}", data=data)
|
await self._request("POST", f"{self.url}/{self._endpoints['data']}", data)
|
||||||
|
|
||||||
def correct_offset(self, device_name: str, real_temp: float):
|
async def correct_offset(self, device_name: str, real_temp: float) -> None:
|
||||||
elapsed = None
|
elapsed = None
|
||||||
if device_name in self.update_time.keys():
|
if device_name in self.update_time.keys():
|
||||||
elapsed = datetime.now() - self.update_time[device_name]
|
elapsed = datetime.now() - self.update_time[device_name]
|
||||||
logging.info(f"Last update for {device_name} {elapsed} ago")
|
logger.debug("Last update for %s %s ago", device_name, elapsed)
|
||||||
delta = timedelta(minutes=self.update_timeout)
|
delta = timedelta(minutes=self.update_timeout)
|
||||||
if device_name not in self.update_time.keys() or elapsed > delta:
|
if (
|
||||||
device: Optional[Device] = self.get_device_data(name=device_name)
|
device_name not in self.update_time.keys()
|
||||||
|
or elapsed is None
|
||||||
|
or elapsed > delta
|
||||||
|
):
|
||||||
|
device: Optional[Device] = await self.get_device_data(name=device_name)
|
||||||
if device is None:
|
if device is None:
|
||||||
return
|
return
|
||||||
new_offset = device.get_offset() + real_temp - device.get_temperature()
|
current_offset = device.get_offset()
|
||||||
logging.info(f"Update offset from {device.get_offset()} to {new_offset}")
|
current_temp = device.get_temperature()
|
||||||
|
if current_offset is None or current_temp is None:
|
||||||
|
logger.warning(
|
||||||
|
"Skipping offset update for %s: missing temperature data",
|
||||||
|
device_name,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
new_offset = current_offset + real_temp - current_temp
|
||||||
|
logger.info(
|
||||||
|
"Update offset for %s from %.2f to %.2f",
|
||||||
|
device_name,
|
||||||
|
current_offset,
|
||||||
|
new_offset,
|
||||||
|
)
|
||||||
device.set_offset(new_offset)
|
device.set_offset(new_offset)
|
||||||
self.set_offset(device)
|
await self.set_offset(device)
|
||||||
self.update_time[device.display_name] = datetime.now()
|
update_key = device.display_name or device_name
|
||||||
|
self.update_time[update_key] = datetime.now()
|
||||||
|
else:
|
||||||
|
logger.debug(
|
||||||
|
"Skip offset update for %s: last update %s ago (min %s)",
|
||||||
|
device_name,
|
||||||
|
elapsed,
|
||||||
|
delta,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def close(self) -> None:
|
||||||
|
await self.session.aclose()
|
||||||
|
|
||||||
|
def _normalize_login_redirect(self, location: str) -> tuple[str, Optional[str]]:
|
||||||
|
parsed_location = urlparse(location)
|
||||||
|
if parsed_location.scheme and parsed_location.netloc:
|
||||||
|
base = f"{parsed_location.scheme}://{parsed_location.netloc}"
|
||||||
|
else:
|
||||||
|
parsed_base = urlparse(self.url)
|
||||||
|
base = f"{parsed_base.scheme}://{parsed_base.netloc}"
|
||||||
|
if location.endswith(self._endpoints["login"]):
|
||||||
|
return location, base
|
||||||
|
return f"{base}/{self._endpoints['login']}", base
|
||||||
|
|||||||
23
fritz_temp_sync/ha_types.py
Normal file
23
fritz_temp_sync/ha_types.py
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import TypedDict
|
||||||
|
|
||||||
|
|
||||||
|
class HAAttributes(TypedDict, total=False):
|
||||||
|
current_temperature: float
|
||||||
|
friendly_name: str
|
||||||
|
|
||||||
|
|
||||||
|
class HAState(TypedDict, total=False):
|
||||||
|
entity_id: str
|
||||||
|
state: str
|
||||||
|
attributes: HAAttributes
|
||||||
|
|
||||||
|
|
||||||
|
class HAEventData(TypedDict):
|
||||||
|
entity_id: str
|
||||||
|
new_state: HAState
|
||||||
|
|
||||||
|
|
||||||
|
class HAEvent(TypedDict):
|
||||||
|
data: HAEventData
|
||||||
@ -3,34 +3,39 @@ from __future__ import annotations
|
|||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
from asyncio import Queue, Task, Event, Lock
|
from asyncio import Event, Lock, Queue, Task
|
||||||
from typing import Callable, Dict, Optional
|
from typing import Any, Dict, Optional, cast
|
||||||
import websockets
|
|
||||||
|
from errors import HomeAssistantError
|
||||||
|
from ha_types import HAEvent
|
||||||
|
from websockets.client import connect as ws_connect
|
||||||
from websockets.exceptions import InvalidStatusCode
|
from websockets.exceptions import InvalidStatusCode
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class HomeAssistantAPI:
|
class HomeAssistantAPI:
|
||||||
def __init__(self, token: str, url: str) -> None:
|
def __init__(self, token: str, url: str) -> None:
|
||||||
self.token = token
|
self.token = token
|
||||||
self.msg_id = 1
|
self.msg_id = 1
|
||||||
self.msg_id_lock = Lock()
|
self.msg_id_lock = Lock()
|
||||||
self.ws: websockets.WebSocketClientProtocol = None
|
self.ws: Any = None
|
||||||
self.url = url
|
self.url = url
|
||||||
self.receiver: Optional[Task] = None
|
self.receiver: Optional[Task[Any]] = None
|
||||||
self.sender: Optional[Task] = None
|
self.sender: Optional[Task[Any]] = None
|
||||||
self.sending_queue: Queue = Queue()
|
self.sending_queue: Queue[Dict[str, Any]] = Queue()
|
||||||
self.authenticated: Event = Event()
|
self.authenticated: Event = Event()
|
||||||
self.events: Dict[int, Queue] = {}
|
self.events: Dict[int, Queue[HAEvent]] = {}
|
||||||
self.responses: Dict[int, Dict] = {}
|
self.responses: Dict[int, Dict[str, Any]] = {}
|
||||||
self.response_events: Dict[int, Event] = {}
|
self.response_events: Dict[int, Event] = {}
|
||||||
self.response_lock: Lock = Lock()
|
self.response_lock: Lock = Lock()
|
||||||
|
|
||||||
async def connect(self):
|
async def connect(self):
|
||||||
retries = 5
|
retries = 5
|
||||||
logging.info("Connect to home assistant...")
|
logger.info("Connect to Home Assistant")
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
self.ws = await websockets.connect(self.url)
|
self.ws = await ws_connect(self.url)
|
||||||
self.sender = asyncio.create_task(self.sending())
|
self.sender = asyncio.create_task(self.sending())
|
||||||
await self.auth()
|
await self.auth()
|
||||||
self.receiver = asyncio.create_task(self.receiving())
|
self.receiver = asyncio.create_task(self.receiving())
|
||||||
@ -39,23 +44,30 @@ class HomeAssistantAPI:
|
|||||||
if retries > 0:
|
if retries > 0:
|
||||||
retries -= 1
|
retries -= 1
|
||||||
await asyncio.sleep(30)
|
await asyncio.sleep(30)
|
||||||
logging.info(f"Retry home assistant connection... ({retries})")
|
logger.warning(
|
||||||
|
"Retry Home Assistant connection (%s retries left)", retries
|
||||||
|
)
|
||||||
continue
|
continue
|
||||||
else:
|
logger.error("Invalid status code while connecting to Home Assistant")
|
||||||
logging.error("Invalid status code while connecting to Home Assistant")
|
|
||||||
await self.exit_loop()
|
await self.exit_loop()
|
||||||
return False
|
raise HomeAssistantError(
|
||||||
|
"Invalid status code while connecting to Home Assistant"
|
||||||
|
)
|
||||||
|
|
||||||
async def wait_for_close(self):
|
async def wait_for_close(self):
|
||||||
await self.ws.wait_closed()
|
await self.ws.wait_closed()
|
||||||
|
|
||||||
async def receiving(self):
|
async def receiving(self):
|
||||||
logging.debug("Start receiving")
|
logger.debug("Start receiving")
|
||||||
async for message in self.ws:
|
async for message in self.ws:
|
||||||
msg: Dict = json.loads(message)
|
msg: Dict[str, Any] = json.loads(cast(str, message))
|
||||||
if msg["type"] == "event":
|
if msg["type"] == "event":
|
||||||
if msg["id"] not in self.events.keys():
|
if msg["id"] not in self.events.keys():
|
||||||
logging.error(f"Received event for not subscribted id: {msg['id']} {msg['event_type']}")
|
logger.warning(
|
||||||
|
"Received event for not subscribed id: %s %s",
|
||||||
|
msg["id"],
|
||||||
|
msg.get("event_type"),
|
||||||
|
)
|
||||||
continue
|
continue
|
||||||
await self.events[msg["id"]].put(msg["event"])
|
await self.events[msg["id"]].put(msg["event"])
|
||||||
else:
|
else:
|
||||||
@ -64,7 +76,7 @@ class HomeAssistantAPI:
|
|||||||
if msg["id"] in self.response_events.keys():
|
if msg["id"] in self.response_events.keys():
|
||||||
self.response_events[msg["id"]].set()
|
self.response_events[msg["id"]].set()
|
||||||
|
|
||||||
async def wait_for(self, idx):
|
async def wait_for(self, idx: int):
|
||||||
async with self.response_lock:
|
async with self.response_lock:
|
||||||
if idx in self.responses.keys():
|
if idx in self.responses.keys():
|
||||||
msg = self.responses[idx]
|
msg = self.responses[idx]
|
||||||
@ -76,7 +88,7 @@ class HomeAssistantAPI:
|
|||||||
async with self.response_lock:
|
async with self.response_lock:
|
||||||
del self.response_events[idx]
|
del self.response_events[idx]
|
||||||
if idx not in self.responses.keys():
|
if idx not in self.responses.keys():
|
||||||
logging.error("Response ID not found")
|
logger.warning("Response ID not found")
|
||||||
return None
|
return None
|
||||||
msg = self.responses[idx]
|
msg = self.responses[idx]
|
||||||
del self.responses[idx]
|
del self.responses[idx]
|
||||||
@ -88,26 +100,31 @@ class HomeAssistantAPI:
|
|||||||
if self.receiver is not None:
|
if self.receiver is not None:
|
||||||
self.receiver.cancel()
|
self.receiver.cancel()
|
||||||
|
|
||||||
|
async def close(self) -> None:
|
||||||
|
await self.exit_loop()
|
||||||
|
if self.ws is not None:
|
||||||
|
try:
|
||||||
|
await self.ws.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
async def auth(self):
|
async def auth(self):
|
||||||
msg = json.loads(await self.ws.recv())
|
msg = json.loads(await self.ws.recv())
|
||||||
if msg["type"] != "auth_required":
|
if msg["type"] != "auth_required":
|
||||||
logging.error("Authentication error: Not required")
|
|
||||||
await self.exit_loop()
|
await self.exit_loop()
|
||||||
response = {
|
raise HomeAssistantError("Authentication error: Not required")
|
||||||
"type": "auth",
|
response: Dict[str, Any] = {"type": "auth", "access_token": self.token}
|
||||||
"access_token": self.token
|
|
||||||
}
|
|
||||||
await self.sending_queue.put(response)
|
await self.sending_queue.put(response)
|
||||||
msg = json.loads(await self.ws.recv())
|
msg = json.loads(await self.ws.recv())
|
||||||
if msg["type"] == "auth_invalid":
|
if msg["type"] == "auth_invalid":
|
||||||
logging.info("Auth failed")
|
|
||||||
await self.exit_loop()
|
await self.exit_loop()
|
||||||
|
raise HomeAssistantError("Auth failed")
|
||||||
elif msg["type"] == "auth_ok":
|
elif msg["type"] == "auth_ok":
|
||||||
logging.debug("Authenticated")
|
logger.info("Authenticated")
|
||||||
self.authenticated.set()
|
self.authenticated.set()
|
||||||
else:
|
else:
|
||||||
logging.error(f"Unknown answer for auth: {msg}")
|
|
||||||
await self.exit_loop()
|
await self.exit_loop()
|
||||||
|
raise HomeAssistantError(f"Unknown answer for auth: {msg}")
|
||||||
|
|
||||||
async def sending(self):
|
async def sending(self):
|
||||||
while msg := await self.sending_queue.get():
|
while msg := await self.sending_queue.get():
|
||||||
@ -116,34 +133,33 @@ class HomeAssistantAPI:
|
|||||||
async def subscribe_event(self, event_type: str):
|
async def subscribe_event(self, event_type: str):
|
||||||
await self.authenticated.wait()
|
await self.authenticated.wait()
|
||||||
|
|
||||||
logging.info(f"Subscribe to {event_type}")
|
logger.info("Subscribe to %s", event_type)
|
||||||
async with self.msg_id_lock:
|
async with self.msg_id_lock:
|
||||||
msg_id = self.msg_id
|
msg_id = self.msg_id
|
||||||
response = {
|
response: Dict[str, Any] = {
|
||||||
"id": msg_id,
|
"id": msg_id,
|
||||||
"type": "subscribe_events",
|
"type": "subscribe_events",
|
||||||
"event_type": event_type
|
"event_type": event_type,
|
||||||
}
|
}
|
||||||
self.events[msg_id] = Queue()
|
self.events[msg_id] = Queue()
|
||||||
self.msg_id += 1
|
self.msg_id += 1
|
||||||
await self.sending_queue.put(response)
|
await self.sending_queue.put(response)
|
||||||
return msg_id
|
return msg_id
|
||||||
|
|
||||||
async def get_states(self):
|
async def get_states(self) -> list[Dict[str, Any]]:
|
||||||
await self.authenticated.wait()
|
await self.authenticated.wait()
|
||||||
async with self.msg_id_lock:
|
async with self.msg_id_lock:
|
||||||
message = {
|
message: Dict[str, Any] = {"id": self.msg_id, "type": "get_states"}
|
||||||
"id": self.msg_id,
|
|
||||||
"type": "get_states"
|
|
||||||
}
|
|
||||||
self.msg_id += 1
|
self.msg_id += 1
|
||||||
await self.sending_queue.put(message)
|
await self.sending_queue.put(message)
|
||||||
|
|
||||||
response = await self.wait_for(message["id"])
|
response = await self.wait_for(cast(int, message["id"]))
|
||||||
# ToDo: Error handling
|
# ToDo: Error handling
|
||||||
return response["result"]
|
if response is None:
|
||||||
|
return []
|
||||||
|
return cast(list[Dict[str, Any]], response.get("result", []))
|
||||||
|
|
||||||
async def get_device_state(self, entity_id: str):
|
async def get_device_state(self, entity_id: str) -> Optional[Dict[str, Any]]:
|
||||||
device_states = await self.get_states()
|
device_states = await self.get_states()
|
||||||
for device_state in device_states:
|
for device_state in device_states:
|
||||||
if device_state["entity_id"] == entity_id:
|
if device_state["entity_id"] == entity_id:
|
||||||
|
|||||||
16
fritz_temp_sync/logging_config.py
Normal file
16
fritz_temp_sync/logging_config.py
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import Union
|
||||||
|
|
||||||
|
|
||||||
|
def configure_logging(level: Union[str, int]) -> None:
|
||||||
|
numeric_level = level
|
||||||
|
if isinstance(level, str):
|
||||||
|
numeric_level = logging.getLevelName(level.upper())
|
||||||
|
if isinstance(numeric_level, str):
|
||||||
|
numeric_level = logging.INFO
|
||||||
|
logging.basicConfig(
|
||||||
|
level=numeric_level,
|
||||||
|
format="[%(asctime)s] [%(levelname)s] [%(name)s] %(message)s",
|
||||||
|
)
|
||||||
215
fritz_temp_sync/sync_controller.py
Normal file
215
fritz_temp_sync/sync_controller.py
Normal file
@ -0,0 +1,215 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
from typing import Any, Optional, cast
|
||||||
|
|
||||||
|
from config import Mapping
|
||||||
|
from errors import FritzBoxError, HomeAssistantError
|
||||||
|
from fritzbox import FritzBox
|
||||||
|
from ha_types import HAEvent
|
||||||
|
from homeassistant import HomeAssistantAPI
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class SyncController:
|
||||||
|
def __init__(
|
||||||
|
self, ha: HomeAssistantAPI, fb: FritzBox, offset_threshold: float
|
||||||
|
) -> None:
|
||||||
|
self.ha = ha
|
||||||
|
self.fb = fb
|
||||||
|
self.offset_threshold = offset_threshold
|
||||||
|
self.sensor_mappings: dict[str, list[str]] = {}
|
||||||
|
self.thermostate_mappings: dict[str, str] = {}
|
||||||
|
self._offset_queue: asyncio.Queue[Optional[tuple[str, float]]] = asyncio.Queue()
|
||||||
|
self._worker_task: Optional[asyncio.Task[None]] = None
|
||||||
|
self._event_task: Optional[asyncio.Task[None]] = None
|
||||||
|
|
||||||
|
def load_mappings(self, mappings: list[Mapping]) -> None:
|
||||||
|
for mapping in mappings:
|
||||||
|
sensor = mapping.sensor
|
||||||
|
thermostate = mapping.thermostate
|
||||||
|
if sensor not in self.sensor_mappings.keys():
|
||||||
|
self.sensor_mappings[sensor] = []
|
||||||
|
self.sensor_mappings[sensor].append(thermostate)
|
||||||
|
self.thermostate_mappings[thermostate] = sensor
|
||||||
|
logger.debug(
|
||||||
|
"Loaded %s sensor mappings and %s thermostat mappings",
|
||||||
|
len(self.sensor_mappings),
|
||||||
|
len(self.thermostate_mappings),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def handle_event(self, idx: int):
|
||||||
|
logger.debug("Wait for events for subscription %s", idx)
|
||||||
|
|
||||||
|
event_queue = cast(Any, self.ha).events[idx]
|
||||||
|
while event := await event_queue.get():
|
||||||
|
event = cast(HAEvent, event)
|
||||||
|
try:
|
||||||
|
entity_id = event["data"]["entity_id"]
|
||||||
|
if not entity_id:
|
||||||
|
continue
|
||||||
|
if (
|
||||||
|
entity_id in self.sensor_mappings.keys()
|
||||||
|
or entity_id in self.thermostate_mappings.keys()
|
||||||
|
):
|
||||||
|
state = cast(Any, await self.ha.get_device_state(entity_id))
|
||||||
|
if not isinstance(state, dict):
|
||||||
|
continue
|
||||||
|
state = cast(dict[str, Any], state)
|
||||||
|
new_state = cast(dict[str, Any], event["data"]["new_state"])
|
||||||
|
logger.debug(
|
||||||
|
"state_changed for %s (is_thermostat=%s is_sensor=%s state=%s)",
|
||||||
|
entity_id,
|
||||||
|
entity_id in self.thermostate_mappings.keys(),
|
||||||
|
entity_id in self.sensor_mappings.keys(),
|
||||||
|
state.get("state"),
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
entity_id in self.thermostate_mappings.keys()
|
||||||
|
and state.get("state") != "unavailable"
|
||||||
|
):
|
||||||
|
therm_temp = cast(
|
||||||
|
float, new_state["attributes"]["current_temperature"]
|
||||||
|
)
|
||||||
|
therm_name = cast(str, new_state["attributes"]["friendly_name"])
|
||||||
|
sensor = self.thermostate_mappings[entity_id]
|
||||||
|
sensor_state = cast(Any, await self.ha.get_device_state(sensor))
|
||||||
|
if not isinstance(sensor_state, dict):
|
||||||
|
continue
|
||||||
|
sensor_state = cast(dict[str, Any], sensor_state)
|
||||||
|
sensor_temp = (
|
||||||
|
round(float(cast(str, sensor_state["state"])) * 2) / 2
|
||||||
|
)
|
||||||
|
logger.debug(
|
||||||
|
"Temps for %s: thermostat=%s sensor=%s",
|
||||||
|
entity_id,
|
||||||
|
therm_temp,
|
||||||
|
sensor_temp,
|
||||||
|
)
|
||||||
|
diff = abs(therm_temp - sensor_temp)
|
||||||
|
if diff < self.offset_threshold:
|
||||||
|
logger.debug(
|
||||||
|
"Offset diff %.2f below threshold %.2f for %s",
|
||||||
|
diff,
|
||||||
|
self.offset_threshold,
|
||||||
|
therm_name,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
if therm_temp != sensor_temp:
|
||||||
|
logger.info(
|
||||||
|
"Offset correction: %s thermostat=%s sensor=%s (from %s)",
|
||||||
|
therm_name,
|
||||||
|
therm_temp,
|
||||||
|
sensor_temp,
|
||||||
|
sensor,
|
||||||
|
)
|
||||||
|
await self._enqueue_offset(therm_name, sensor_temp)
|
||||||
|
|
||||||
|
elif entity_id in self.sensor_mappings.keys():
|
||||||
|
logger.debug("sensor update for %s", entity_id)
|
||||||
|
sensor_temp = (
|
||||||
|
round(float(cast(str, new_state["state"])) * 2) / 2
|
||||||
|
)
|
||||||
|
logger.debug(
|
||||||
|
"thermostats for %s: %s",
|
||||||
|
entity_id,
|
||||||
|
self.sensor_mappings[entity_id],
|
||||||
|
)
|
||||||
|
for thermostate in self.sensor_mappings[entity_id]:
|
||||||
|
logger.debug("check thermostat %s", thermostate)
|
||||||
|
therm_state = cast(
|
||||||
|
Any, await self.ha.get_device_state(thermostate)
|
||||||
|
)
|
||||||
|
if not isinstance(therm_state, dict):
|
||||||
|
continue
|
||||||
|
therm_state = cast(dict[str, Any], therm_state)
|
||||||
|
if therm_state.get("state") == "unavailable":
|
||||||
|
logger.debug("thermostat %s unavailable", thermostate)
|
||||||
|
continue
|
||||||
|
therm_temp = float(
|
||||||
|
cast(
|
||||||
|
float,
|
||||||
|
therm_state["attributes"]["current_temperature"],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
therm_name = cast(
|
||||||
|
str, therm_state["attributes"]["friendly_name"]
|
||||||
|
)
|
||||||
|
logger.debug(
|
||||||
|
"Temps for %s: thermostat=%s sensor=%s",
|
||||||
|
thermostate,
|
||||||
|
therm_temp,
|
||||||
|
sensor_temp,
|
||||||
|
)
|
||||||
|
diff = abs(therm_temp - sensor_temp)
|
||||||
|
if diff < self.offset_threshold:
|
||||||
|
logger.debug(
|
||||||
|
"Offset diff %.2f below threshold %.2f for %s",
|
||||||
|
diff,
|
||||||
|
self.offset_threshold,
|
||||||
|
therm_name,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
if therm_temp != sensor_temp:
|
||||||
|
logger.info(
|
||||||
|
"Offset correction: %s thermostat=%s sensor=%s (from %s)",
|
||||||
|
therm_name,
|
||||||
|
therm_temp,
|
||||||
|
sensor_temp,
|
||||||
|
entity_id,
|
||||||
|
)
|
||||||
|
await self._enqueue_offset(therm_name, sensor_temp)
|
||||||
|
except KeyError:
|
||||||
|
continue
|
||||||
|
|
||||||
|
async def _enqueue_offset(self, device_name: str, sensor_temp: float) -> None:
|
||||||
|
await self._offset_queue.put((device_name, sensor_temp))
|
||||||
|
|
||||||
|
async def _offset_worker(self) -> None:
|
||||||
|
while True:
|
||||||
|
item = await self._offset_queue.get()
|
||||||
|
if item is None:
|
||||||
|
break
|
||||||
|
device_name, sensor_temp = item
|
||||||
|
try:
|
||||||
|
await self.fb.correct_offset(device_name, sensor_temp)
|
||||||
|
except FritzBoxError as exc:
|
||||||
|
logger.error("Offset update failed for %s: %s", device_name, exc)
|
||||||
|
|
||||||
|
async def run(self) -> None:
|
||||||
|
try:
|
||||||
|
await self.ha.connect()
|
||||||
|
except HomeAssistantError as exc:
|
||||||
|
logger.error("Home Assistant error: %s", exc)
|
||||||
|
return
|
||||||
|
logger.debug("Subscribing to state_changed")
|
||||||
|
state_changed_id = await self.ha.subscribe_event("state_changed")
|
||||||
|
logger.debug(state_changed_id)
|
||||||
|
self._event_task = asyncio.create_task(self.handle_event(state_changed_id))
|
||||||
|
try:
|
||||||
|
try:
|
||||||
|
if not await self.fb.login():
|
||||||
|
raise FritzBoxError("Failed to login to Fritz!Box")
|
||||||
|
except FritzBoxError as exc:
|
||||||
|
logger.error("Fritz!Box error: %s", exc)
|
||||||
|
return
|
||||||
|
logger.info("Health check OK")
|
||||||
|
self._worker_task = asyncio.create_task(self._offset_worker())
|
||||||
|
try:
|
||||||
|
await self.ha.wait_for_close()
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
await self.shutdown()
|
||||||
|
|
||||||
|
async def shutdown(self) -> None:
|
||||||
|
if self._event_task is not None:
|
||||||
|
self._event_task.cancel()
|
||||||
|
await self._offset_queue.put(None)
|
||||||
|
if self._worker_task is not None:
|
||||||
|
await self._worker_task
|
||||||
|
await self.ha.close()
|
||||||
|
await self.fb.close()
|
||||||
|
logger.info("Shutdown complete")
|
||||||
@ -1,125 +1,69 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import json
|
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
|
from config import load_config
|
||||||
|
from errors import ConfigError
|
||||||
from fritzbox import FritzBox
|
from fritzbox import FritzBox
|
||||||
from homeassistant import HomeAssistantAPI
|
from homeassistant import HomeAssistantAPI
|
||||||
|
from logging_config import configure_logging
|
||||||
|
from sync_controller import SyncController
|
||||||
|
|
||||||
sensor_mappings = {}
|
logger = logging.getLogger(__name__)
|
||||||
thermostate_mappings = {}
|
|
||||||
|
|
||||||
|
|
||||||
async def handle_event(idx: int):
|
|
||||||
global ha, fb
|
|
||||||
logging.debug(f"Wait for events for {idx}")
|
|
||||||
|
|
||||||
while event := await ha.events[idx].get():
|
|
||||||
try:
|
|
||||||
entity_id = event["data"]["entity_id"]
|
|
||||||
if (
|
|
||||||
entity_id in sensor_mappings.keys()
|
|
||||||
or entity_id in thermostate_mappings.keys()
|
|
||||||
):
|
|
||||||
state = await ha.get_device_state(entity_id)
|
|
||||||
new_state = event["data"]["new_state"]
|
|
||||||
logging.info(
|
|
||||||
f"received changed state from {entity_id} {entity_id in thermostate_mappings.keys()} {state['state']} {entity_id in sensor_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["state"]) * 2) / 2
|
|
||||||
logging.info(f"temps: {therm_temp} {sensor_temp}")
|
|
||||||
if therm_temp != sensor_temp:
|
|
||||||
logging.info(
|
|
||||||
f"{therm_name}: {therm_temp}\n{sensor}: {sensor_state['state']} ({sensor_temp})"
|
|
||||||
)
|
|
||||||
fb.correct_offset(therm_name, sensor_temp)
|
|
||||||
|
|
||||||
elif entity_id in sensor_mappings.keys():
|
|
||||||
logging.info(f"here {sensor_mappings} {entity_id}")
|
|
||||||
logging.info(f"{new_state}")
|
|
||||||
sensor_temp = round(float(new_state["state"]) * 2) / 2
|
|
||||||
logging.info(f"entry: {sensor_mappings[entity_id]}")
|
|
||||||
for thermostate in sensor_mappings[entity_id]:
|
|
||||||
logging.info(thermostate)
|
|
||||||
therm_state = await ha.get_device_state(thermostate)
|
|
||||||
logging.info(f"{thermostate} {therm_state}")
|
|
||||||
if therm_state["state"] == "unavailable":
|
|
||||||
continue
|
|
||||||
therm_temp = float(
|
|
||||||
therm_state["attributes"]["current_temperature"]
|
|
||||||
)
|
|
||||||
therm_name = therm_state["attributes"]["friendly_name"]
|
|
||||||
logging.info(f"Temps: {therm_temp} {sensor_temp}")
|
|
||||||
if therm_temp != sensor_temp:
|
|
||||||
logging.info(
|
|
||||||
f"{therm_name}: {therm_temp}\n{entity_id}: {new_state['state']} ({sensor_temp})"
|
|
||||||
)
|
|
||||||
fb.correct_offset(therm_name, sensor_temp)
|
|
||||||
except KeyError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
async def init(ha: HomeAssistantAPI, fb: FritzBox):
|
|
||||||
if not await ha.connect():
|
|
||||||
return
|
|
||||||
logging.debug("Subscribe")
|
|
||||||
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..")
|
|
||||||
|
|
||||||
|
|
||||||
async def main():
|
async def main():
|
||||||
config_path = sys.argv[1]
|
config_path = sys.argv[1]
|
||||||
config = json.load(open(config_path))
|
try:
|
||||||
level = logging.INFO
|
config = load_config(config_path)
|
||||||
if "log_level" in config:
|
except ConfigError as exc:
|
||||||
print(f"Setting log_level {config['log_level']}")
|
logging.basicConfig(level=logging.ERROR)
|
||||||
if config["log_level"] == "DEBUG":
|
logging.error("Invalid config: %s", exc)
|
||||||
level = logging.DEBUG
|
return
|
||||||
logging.basicConfig(
|
configure_logging(config.log_level)
|
||||||
level=level, format="[%(asctime)s] [%(levelname)s] %(message)s"
|
logger.debug("Loaded config")
|
||||||
)
|
if not config.log_ws_messages:
|
||||||
logging.debug(config)
|
logging.getLogger("websockets").setLevel(logging.WARNING)
|
||||||
|
logging.getLogger("websockets.client").setLevel(logging.WARNING)
|
||||||
|
logging.getLogger("websockets.server").setLevel(logging.WARNING)
|
||||||
|
if not config.log_http_requests:
|
||||||
|
logging.getLogger("httpx").setLevel(logging.WARNING)
|
||||||
|
logging.getLogger("httpcore").setLevel(logging.WARNING)
|
||||||
|
|
||||||
|
fritzbox = config.fritzbox
|
||||||
|
|
||||||
global fb
|
|
||||||
fb = FritzBox(
|
fb = FritzBox(
|
||||||
url=config["fritzbox"]["url"],
|
url=fritzbox.url,
|
||||||
user=config["fritzbox"]["username"],
|
user=fritzbox.username,
|
||||||
password=config["fritzbox"]["password"],
|
password=fritzbox.password,
|
||||||
update_timeout=config["update_timeout"],
|
update_timeout=config.update_timeout,
|
||||||
dry_run=False,
|
dry_run=config.dry_run,
|
||||||
|
force_ipv4=config.force_ipv4,
|
||||||
|
request_timeout=config.request_timeout,
|
||||||
|
request_retries=config.request_retries,
|
||||||
)
|
)
|
||||||
supervisor_url = "ws://supervisor/core/websocket"
|
supervisor_url = "ws://supervisor/core/websocket"
|
||||||
if "SUPERVISOR_URL" in os.environ:
|
if "SUPERVISOR_URL" in os.environ:
|
||||||
supervisor_url = os.environ["SUPERVISOR_URL"]
|
supervisor_url = os.environ["SUPERVISOR_URL"]
|
||||||
supervisor_token = os.environ["SUPERVISOR_TOKEN"]
|
supervisor_token = os.environ.get("SUPERVISOR_TOKEN")
|
||||||
global ha
|
if not supervisor_token:
|
||||||
|
logger.error("Missing SUPERVISOR_TOKEN; cannot connect to Home Assistant")
|
||||||
|
return
|
||||||
ha = HomeAssistantAPI(supervisor_token, supervisor_url)
|
ha = HomeAssistantAPI(supervisor_token, supervisor_url)
|
||||||
|
controller = SyncController(ha, fb, config.offset_threshold)
|
||||||
for mapping in config["mappings"]:
|
controller.load_mappings(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"]
|
|
||||||
logging.debug(f"Mappings: {sensor_mappings} {thermostate_mappings}")
|
|
||||||
try:
|
try:
|
||||||
await init(ha, fb)
|
logger.info("Starting sync controller")
|
||||||
except KeyboardInterrupt:
|
await controller.run()
|
||||||
|
except (KeyboardInterrupt, asyncio.CancelledError):
|
||||||
|
logger.info("Shutdown requested")
|
||||||
|
await controller.shutdown()
|
||||||
|
|
||||||
|
|
||||||
|
try:
|
||||||
|
asyncio.run(main())
|
||||||
|
except KeyboardInterrupt:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
asyncio.run(main())
|
|
||||||
|
|||||||
38
fritz_temp_sync/tests/test_config.py
Normal file
38
fritz_temp_sync/tests/test_config.py
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
# pyright: reportMissingImports=false
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest # type: ignore[import-not-found]
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
sys.path.append(str(ROOT))
|
||||||
|
|
||||||
|
from config import _parse_mappings, load_config # type: ignore[import-not-found]
|
||||||
|
from errors import ConfigError # type: ignore[import-not-found]
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_mappings_rejects_duplicate_thermostat():
|
||||||
|
with pytest.raises(ConfigError):
|
||||||
|
_parse_mappings(
|
||||||
|
[
|
||||||
|
{"sensor": "sensor.a", "thermostate": "climate.x"},
|
||||||
|
{"sensor": "sensor.b", "thermostate": "climate.x"},
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_config_parses_dry_run(tmp_path: Path):
|
||||||
|
payload = {
|
||||||
|
"fritzbox": {"url": "http://fritz.box", "password": "secret"},
|
||||||
|
"mappings": [{"sensor": "sensor.a", "thermostate": "climate.x"}],
|
||||||
|
"update_timeout": 15,
|
||||||
|
"dry_run": True,
|
||||||
|
}
|
||||||
|
config_path = tmp_path / "config.json"
|
||||||
|
config_path.write_text(json.dumps(payload), encoding="utf-8")
|
||||||
|
|
||||||
|
config = load_config(str(config_path))
|
||||||
|
assert config.dry_run is True
|
||||||
Loading…
x
Reference in New Issue
Block a user