"""Use of this source code is governed by the MIT license found in the LICENSE file. Plugwise backend module for Home Assistant Core. """ from __future__ import annotations from collections.abc import Awaitable, Callable import datetime as dt from typing import Any, cast from plugwise.constants import ( ALLOWED_ZONE_PROFILES, ANNA, APPLIANCES, DOMAIN_OBJECTS, GATEWAY_REBOOT, LOCATIONS, MAX_SETPOINT, MIN_SETPOINT, NONE, NOTIFICATIONS, OFF, RULES, STATE_OFF, STATE_ON, GwEntityData, SwitchType, ThermoLoc, ) from plugwise.data import SmileData from plugwise.exceptions import ConnectionFailedError, DataMissingError, PlugwiseError from defusedxml import ElementTree as etree # Dict as class from munch import Munch def model_to_switch_items(model: str, state: str, switch: Munch) -> tuple[str, Munch]: """Translate state and switch attributes based on model name. Helper function for set_switch_state(). """ match model: case "dhw_cm_switch": switch.device = "toggle" switch.func_type = "toggle_functionality" switch.act_type = "domestic_hot_water_comfort_mode" case "cooling_ena_switch": switch.device = "toggle" switch.func_type = "toggle_functionality" switch.act_type = "cooling_enabled" case "lock": switch.func = "lock" state = "true" if state == STATE_ON else "false" return state, switch class SmileAPI(SmileData): """The Plugwise SmileAPI helper class for actual Plugwise devices.""" # pylint: disable=too-many-instance-attributes, too-many-public-methods def __init__( self, _cooling_present: bool, _elga: bool, _is_thermostat: bool, _loc_data: dict[str, ThermoLoc], _on_off_device: bool, _opentherm_device: bool, _request: Callable[..., Awaitable[Any]], _schedule_old_states: dict[str, dict[str, str]], smile: Munch, ) -> None: """Set the constructor for this class.""" super().__init__() self._cooling_present = _cooling_present self._elga = _elga 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._schedule_old_states = _schedule_old_states self.smile = smile self.therms_with_offset_func: list[str] = [] @property def cooling_present(self) -> bool: """Return the cooling capability.""" return self._cooling_present 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._get_plugwise_notifications() 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. If a thermostat-gateway, collect a list of thermostats with offset-capability. Collect and add switching- and/or pump-group entities. Finally, collect the data and states for each entity. """ self._get_appliances() if self._is_thermostat: self.therms_with_offset_func = ( self._get_appliances_with_offset_functionality() ) self._scan_thermostats() self._get_groups() self._all_entity_data() def _get_appliances_with_offset_functionality(self) -> list[str]: """Helper-function collecting all appliance that have offset_functionality.""" therm_list: list[str] = [] offset_appls = self._domain_objects.findall( './/actuator_functionalities/offset_functionality[type="temperature_offset"]/offset/../../..' ) for item in offset_appls: therm_list.append(item.get("id")) return therm_list async def async_update(self) -> dict[str, GwEntityData]: """Perform an full update: re-collect all gateway entities and their data and states. Any change in the connected entities will be detected immediately. """ self._zones = {} self.gw_entities = {} try: await self.full_xml_update() self.get_all_gateway_entities() # Set self._cooling_enabled - required for set_temperature(), # also, check for a failed data-retrieval if self.heater_id != NONE: heat_cooler = self.gw_entities[self.heater_id] if ( "binary_sensors" in heat_cooler and "cooling_enabled" in heat_cooler["binary_sensors"] ): self._cooling_enabled = heat_cooler["binary_sensors"][ "cooling_enabled" ] except KeyError as err: raise DataMissingError(f"No data: {err}") from err return self.gw_entities ######################################################################################################## ### API Set and HA Service-related Functions ### ######################################################################################################## async def delete_notification(self) -> None: """Delete the active Plugwise Notification.""" await self.call_request(NOTIFICATIONS, method="delete") async def reboot_gateway(self) -> None: """Reboot the Gateway.""" await self.call_request(GATEWAY_REBOOT, method="post") async def set_number( self, dev_id: str, key: str, temperature: float, ) -> None: """Set the maximum boiler- or DHW-setpoint on the Central Heating boiler or the temperature-offset on a Thermostat.""" match key: case "temperature_offset": await self.set_offset(dev_id, temperature) return case "max_dhw_temperature": key = "domestic_hot_water_setpoint" temp = str(temperature) thermostat_id: str | None = None locator = f'appliance[@id="{self._heater_id}"]/actuator_functionalities/thermostat_functionality' if th_func_list := self._domain_objects.findall(locator): for th_func in th_func_list: if th_func.find("type").text == key: thermostat_id = th_func.get("id") if thermostat_id is None: raise PlugwiseError(f"Plugwise: cannot change setpoint, {key} not found.") data = ( "" f"{temp}" "" ) uri = f"{APPLIANCES};id={self._heater_id}/thermostat;id={thermostat_id}" await self.call_request(uri, method="put", data=data) async def set_offset(self, dev_id: str, offset: float) -> None: """Set the Temperature offset for thermostats that support this feature.""" if dev_id not in self.therms_with_offset_func: raise PlugwiseError( "Plugwise: this device does not have temperature-offset capability." ) value = str(offset) data = f"{value}" uri = f"{APPLIANCES};id={dev_id}/offset;type=temperature_offset" await self.call_request(uri, method="put", data=data) async def set_preset(self, loc_id: str, preset: str) -> None: """Set the given Preset on the relevant Thermostat - from LOCATIONS.""" if (presets := self._presets(loc_id)) is None: raise PlugwiseError("Plugwise: no presets available.") # pragma: no cover if preset not in list(presets): raise PlugwiseError("Plugwise: invalid preset.") current_location = self._domain_objects.find(f'location[@id="{loc_id}"]') location_name = current_location.find("name").text location_type = current_location.find("type").text data = ( "" f'' f"{location_name}" f"{location_type}" f"{preset}" "" "" ) uri = f"{LOCATIONS};id={loc_id}" await self.call_request(uri, method="put", data=data) async def set_select( self, key: str, loc_id: str, option: str, state: str | None ) -> None: """Set a dhw/gateway/regulation mode or the thermostat schedule option.""" match key: case "select_dhw_mode": await self.set_dhw_mode(option) case "select_gateway_mode": await self.set_gateway_mode(option) case "select_regulation_mode": await self.set_regulation_mode(option) case "select_schedule": # schedule name corresponds to select option await self.set_schedule_state(loc_id, state, option) case "select_zone_profile": await self.set_zone_profile(loc_id, option) async def set_dhw_mode(self, mode: str) -> None: """Set the domestic hot water heating regulation mode.""" if mode not in self._dhw_allowed_modes: raise PlugwiseError("Plugwise: invalid dhw mode.") data = ( "" f"{mode}" "" ) uri = f"{APPLIANCES};type=heater_central/domestic_hot_water_mode_control" await self.call_request(uri, method="put", data=data) async def set_gateway_mode(self, mode: str) -> None: """Set the gateway mode.""" if mode not in self._gw_allowed_modes: raise PlugwiseError("Plugwise: invalid gateway mode.") end_time = "2037-04-21T08:00:53.000Z" valid = "" if mode == "away": time_1 = self._domain_objects.find("./gateway/time").text away_time = ( dt.datetime.fromisoformat(time_1) .astimezone(dt.UTC) .isoformat(timespec="milliseconds") .replace("+00:00", "Z") ) valid = ( f"{away_time}{end_time}" ) if mode == "vacation": time_2 = str(dt.date.today() - dt.timedelta(1)) vacation_time = time_2 + "T23:00:00.000Z" valid = f"{vacation_time}{end_time}" data = ( "" f"{mode}" f"{valid}" "" ) uri = f"{APPLIANCES};id={self.gateway_id}/gateway_mode_control" await self.call_request(uri, method="put", data=data) async def set_regulation_mode(self, mode: str) -> None: """Set the heating regulation mode.""" if mode not in self._reg_allowed_modes: raise PlugwiseError("Plugwise: invalid regulation mode.") duration = "" if "bleeding" in mode: duration = "300" data = ( "" f"{duration}" f"{mode}" "" ) uri = f"{APPLIANCES};type=gateway/regulation_mode_control" await self.call_request(uri, method="put", data=data) async def set_zone_profile(self, loc_id: str, profile: str) -> None: """Set the Adam thermoszone heating profile.""" if profile not in ALLOWED_ZONE_PROFILES: raise PlugwiseError("Plugwise: invalid zone profile.") data = ( "" f"{profile}" "" ) uri = f"{LOCATIONS};id={loc_id}/thermostat" await self.call_request(uri, method="post", data=data) async def set_schedule_state( self, loc_id: str, new_state: str | None, name: str | None, ) -> None: """Activate/deactivate the Schedule, with the given name, on the relevant Thermostat. Determined from - DOMAIN_OBJECTS. Used in HA Core to set the hvac_mode: in practice switch between schedule on - off. """ # Input checking if new_state not in (STATE_OFF, STATE_ON): raise PlugwiseError("Plugwise: invalid schedule state.") # Translate selection of Off-schedule-option to disabling the active schedule if name == OFF: new_state = STATE_OFF # Handle no schedule-name / schedule-off requested: find the active schedule if name is None or name == OFF: _, name = self._schedules(loc_id) if name == OFF: # no active schedule found, nothing to do return schedule_rule = self._rule_ids_by_name(name, loc_id) # Raise an error when the schedule name does not exist if not schedule_rule or schedule_rule is None: raise PlugwiseError("Plugwise: no schedule with this name available.") # If no state change is requested, do nothing if new_state == self._schedule_old_states[loc_id][name]: return schedule_rule_id: str = next(iter(schedule_rule)) template = ( '