1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331
|
"""Use of this source code is governed by the MIT license found in the LICENSE file.
Plugwise backend module for Home Assistant Core - covering the legacy P1, Anna, and Stretch devices.
"""
from __future__ import annotations
from collections.abc import Awaitable, Callable
import datetime as dt
from typing import Any
from plugwise.constants import (
APPLIANCES,
DOMAIN_OBJECTS,
LOCATIONS,
LOGGER,
MODULES,
OFF,
REQUIRE_APPLIANCES,
RULES,
GwEntityData,
ThermoLoc,
)
from plugwise.exceptions import ConnectionFailedError, DataMissingError, PlugwiseError
from plugwise.legacy.data import SmileLegacyData
from munch import Munch
from packaging.version import Version
class SmileLegacyAPI(SmileLegacyData):
"""The Plugwise SmileLegacyAPI helper class for actual Plugwise legacy devices."""
# pylint: disable=too-many-instance-attributes, too-many-public-methods
def __init__(
self,
_is_thermostat: bool,
_loc_data: dict[str, ThermoLoc],
_on_off_device: bool,
_opentherm_device: bool,
_request: Callable[..., Awaitable[Any]],
_stretch_v2: bool,
_target_smile: str,
smile_hostname: str,
smile_hw_version: str | None,
smile_mac_address: str | None,
smile_model: str,
smile_name: str,
smile_type: str,
smile_version: Version,
smile_zigbee_mac_address: str | None,
) -> None:
"""Set the constructor for this class."""
super().__init__()
self._cooling_present = False
self._is_thermostat = _is_thermostat
self._loc_data = _loc_data
self._on_off_device = _on_off_device
self._opentherm_device = _opentherm_device
self._request = _request
self._stretch_v2 = _stretch_v2
self._target_smile = _target_smile
self.smile_hostname = smile_hostname
self.smile_hw_version = smile_hw_version
self.smile_mac_address = smile_mac_address
self.smile_model = smile_model
self.smile_name = smile_name
self.smile_type = smile_type
self.smile_version = smile_version
self.smile_zigbee_mac_address = smile_zigbee_mac_address
self._first_update = True
self._previous_day_number: str = "0"
@property
def cooling_present(self) -> bool:
"""Return the cooling capability."""
return False
async def full_xml_update(self) -> None:
"""Perform a first fetch of the Plugwise server XML data."""
self._domain_objects = await self._request(DOMAIN_OBJECTS)
self._locations = await self._request(LOCATIONS)
self._modules = await self._request(MODULES)
# P1 legacy has no appliances
if self.smile_type != "power":
self._appliances = await self._request(APPLIANCES)
def get_all_gateway_entities(self) -> None:
"""Collect the Plugwise gateway entities and their data and states from the received raw XML-data.
First, collect all the connected entities and their initial data.
Collect and add switching- and/or pump-group entities.
Finally, collect the data and states for each entity.
"""
self._all_appliances()
if group_data := self._get_group_switches():
self.gw_entities.update(group_data)
self._all_entity_data()
async def async_update(self) -> dict[str, GwEntityData]:
"""Perform an full update update at day-change: re-collect all gateway entities and their data and states.
Otherwise perform an incremental update: only collect the entities updated data and states.
"""
day_number = dt.datetime.now().strftime("%w")
if self._first_update or day_number != self._previous_day_number:
LOGGER.info(
"Performing daily full-update, reload the Plugwise integration when a single entity becomes unavailable."
)
try:
await self.full_xml_update()
self.get_all_gateway_entities()
# Detect failed data-retrieval
_ = self.gw_entities[self.gateway_id]["location"]
except KeyError as err: # pragma: no cover
raise DataMissingError(
"No (full) Plugwise legacy data received"
) from err
else:
try:
self._domain_objects = await self._request(DOMAIN_OBJECTS)
match self._target_smile:
case "smile_v2":
self._modules = await self._request(MODULES)
case self._target_smile if self._target_smile in REQUIRE_APPLIANCES:
self._appliances = await self._request(APPLIANCES)
self._update_gw_entities()
# Detect failed data-retrieval
_ = self.gw_entities[self.gateway_id]["location"]
except KeyError as err: # pragma: no cover
raise DataMissingError("No legacy Plugwise data received") from err
self._first_update = False
self._previous_day_number = day_number
return self.gw_entities
########################################################################################################
### API Set and HA Service-related Functions ###
########################################################################################################
async def delete_notification(self) -> None:
"""Set-function placeholder for legacy devices."""
async def reboot_gateway(self) -> None:
"""Set-function placeholder for legacy devices."""
async def set_dhw_mode(self, mode: str) -> None:
"""Set-function placeholder for legacy devices."""
async def set_gateway_mode(self, mode: str) -> None:
"""Set-function placeholder for legacy devices."""
async def set_number(
self,
dev_id: str,
key: str,
temperature: float,
) -> None:
"""Set-function placeholder for legacy devices."""
async def set_offset(self, dev_id: str, offset: float) -> None:
"""Set-function placeholder for legacy devices."""
async def set_preset(self, _: str, preset: str) -> None:
"""Set the given Preset on the relevant Thermostat - from DOMAIN_OBJECTS."""
if (presets := self._presets()) is None:
raise PlugwiseError("Plugwise: no presets available.") # pragma: no cover
if preset not in list(presets):
raise PlugwiseError("Plugwise: invalid preset.")
locator = f'rule/directives/when/then[@icon="{preset}"].../.../...'
rule_id = self._domain_objects.find(locator).attrib["id"]
data = f"<rules><rule id='{rule_id}'><active>true</active></rule></rules>"
await self.call_request(RULES, method="put", data=data)
async def set_regulation_mode(self, mode: str) -> None:
"""Set-function placeholder for legacy devices."""
async def set_select(
self, key: str, loc_id: str, option: str, state: str | None
) -> None:
"""Set the thermostat schedule option."""
# schedule name corresponds to select option
await self.set_schedule_state("dummy", state, option)
async def set_schedule_state(
self, _: str, state: str | None, name: str | None
) -> None:
"""Activate/deactivate the Schedule.
Determined from - DOMAIN_OBJECTS.
Used in HA Core to set the hvac_mode: in practice switch between schedule on - off.
"""
if state not in ("on", "off"):
raise PlugwiseError("Plugwise: invalid schedule state.")
# Handle no schedule-name / Off-schedule provided
if name is None or name == OFF:
name = "Thermostat schedule"
schedule_rule_id: str | None = None
for rule in self._domain_objects.findall("rule"):
if rule.find("name").text == name:
schedule_rule_id = rule.attrib["id"]
break
if schedule_rule_id is None:
raise PlugwiseError(
"Plugwise: no schedule with this name available."
) # pragma: no cover
new_state = "false"
if state == "on":
new_state = "true"
locator = f'.//*[@id="{schedule_rule_id}"]/template'
template_id = self._domain_objects.find(locator).attrib["id"]
data = (
"<rules>"
f"<rule id='{schedule_rule_id}'>"
f"<name><![CDATA[{name}]]></name>"
f"<template id='{template_id}' />"
f"<active>{new_state}</active>"
"</rule>"
"</rules>"
)
uri = f"{RULES};id={schedule_rule_id}"
await self.call_request(uri, method="put", data=data)
async def set_switch_state(
self, appl_id: str, members: list[str] | None, model: str, state: str
) -> None:
"""Set the given state of the relevant switch.
For individual switches, sets the state directly.
For group switches, sets the state for each member in the group separately.
For switch-locks, sets the lock state using a different data format.
"""
switch = Munch()
switch.actuator = "actuator_functionalities"
switch.func_type = "relay_functionality"
if self._stretch_v2:
switch.actuator = "actuators"
switch.func_type = "relay"
# Handle switch-lock
if model == "lock":
state = "false" if state == "off" else "true"
appliance = self._appliances.find(f'appliance[@id="{appl_id}"]')
appl_name = appliance.find("name").text
appl_type = appliance.find("type").text
data = (
"<appliances>"
f"<appliance id='{appl_id}'>"
f"<name><![CDATA[{appl_name}]]></name>"
f"<description><![CDATA[]]></description>"
f"<type><![CDATA[{appl_type}]]></type>"
f"<{switch.actuator}>"
f"<{switch.func_type}>"
f"<lock>{state}</lock>"
f"</{switch.func_type}>"
f"</{switch.actuator}>"
"</appliance>"
"</appliances>"
)
await self.call_request(APPLIANCES, method="post", data=data)
return
# Handle group of switches
data = f"<{switch.func_type}><state>{state}</state></{switch.func_type}>"
if members is not None:
return await self._set_groupswitch_member_state(
data, members, state, switch
)
# Handle individual relay switches
uri = f"{APPLIANCES};id={appl_id}/relay"
if model == "relay":
locator = (
f'appliance[@id="{appl_id}"]/{switch.actuator}/{switch.func_type}/lock'
)
# Don't bother switching a relay when the corresponding lock-state is true
if self._appliances.find(locator).text == "true":
raise PlugwiseError("Plugwise: the locked Relay was not switched.")
await self.call_request(uri, method="put", data=data)
async def _set_groupswitch_member_state(
self, data: str, members: list[str], state: str, switch: Munch
) -> None:
"""Helper-function for set_switch_state().
Set the given State of the relevant Switch (relay) within a group of members.
"""
for member in members:
uri = f"{APPLIANCES};id={member}/relay"
await self.call_request(uri, method="put", data=data)
async def set_temperature(self, _: str, items: dict[str, float]) -> None:
"""Set the given Temperature on the relevant Thermostat."""
setpoint: float | None = None
if "setpoint" in items:
setpoint = items["setpoint"]
if setpoint is None:
raise PlugwiseError(
"Plugwise: failed setting temperature: no valid input provided"
) # pragma: no cover"
temperature = str(setpoint)
data = (
"<thermostat_functionality>"
f"<setpoint>{temperature}</setpoint>"
"</thermostat_functionality>"
)
uri = self._thermostat_uri()
await self.call_request(uri, method="put", data=data)
async def call_request(self, uri: str, **kwargs: Any) -> None:
"""ConnectionFailedError wrapper for calling request()."""
method: str = kwargs["method"]
data: str | None = kwargs.get("data")
try:
await self._request(uri, method=method, data=data)
except ConnectionFailedError as exc:
raise ConnectionFailedError from exc
|