plugwise 0.36.3__py3-none-any.whl → 0.37.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,272 @@
1
+ """Use of this source code is governed by the MIT license found in the LICENSE file.
2
+
3
+ Plugwise backend module for Home Assistant Core - covering the legacy P1, Anna, and Stretch devices.
4
+ """
5
+ from __future__ import annotations
6
+
7
+ import datetime as dt
8
+
9
+ # Version detection
10
+ from plugwise.constants import (
11
+ APPLIANCES,
12
+ DEFAULT_PORT,
13
+ DEFAULT_TIMEOUT,
14
+ DEFAULT_USERNAME,
15
+ DOMAIN_OBJECTS,
16
+ LOCATIONS,
17
+ LOGGER,
18
+ MODULES,
19
+ REQUIRE_APPLIANCES,
20
+ RULES,
21
+ DeviceData,
22
+ GatewayData,
23
+ PlugwiseData,
24
+ ThermoLoc,
25
+ )
26
+ from plugwise.exceptions import PlugwiseError
27
+ from plugwise.helper import SmileComm
28
+ from plugwise.legacy.data import SmileLegacyData
29
+
30
+ import aiohttp
31
+ from munch import Munch
32
+ import semver
33
+
34
+
35
+ class SmileLegacyAPI(SmileComm, SmileLegacyData):
36
+ """The Plugwise SmileLegacyAPI class."""
37
+
38
+ # pylint: disable=too-many-instance-attributes, too-many-public-methods
39
+
40
+ def __init__(
41
+ self,
42
+ host: str,
43
+ password: str,
44
+ websession: aiohttp.ClientSession,
45
+ _is_thermostat: bool,
46
+ _on_off_device: bool,
47
+ _opentherm_device: bool,
48
+ _stretch_v2: bool,
49
+ _target_smile: str,
50
+ loc_data: dict[str, ThermoLoc],
51
+ smile_fw_version: str | None,
52
+ smile_hostname: str,
53
+ smile_hw_version: str | None,
54
+ smile_mac_address: str | None,
55
+ smile_model: str,
56
+ smile_name: str,
57
+ smile_type: str,
58
+ smile_version: tuple[str, semver.version.Version],
59
+ smile_zigbee_mac_address: str | None,
60
+ username: str = DEFAULT_USERNAME,
61
+ port: int = DEFAULT_PORT,
62
+ timeout: float = DEFAULT_TIMEOUT,
63
+ ) -> None:
64
+ """Set the constructor for this class."""
65
+ super().__init__(
66
+ host,
67
+ password,
68
+ websession,
69
+ username,
70
+ port,
71
+ timeout,
72
+ )
73
+ SmileLegacyData.__init__(self)
74
+
75
+ self._is_thermostat = _is_thermostat
76
+ self._on_off_device = _on_off_device
77
+ self._opentherm_device = _opentherm_device
78
+ self._stretch_v2 = _stretch_v2
79
+ self._target_smile = _target_smile
80
+ self.loc_data = loc_data
81
+ self.smile_fw_version = smile_fw_version
82
+ self.smile_hostname = smile_hostname
83
+ self.smile_hw_version = smile_hw_version
84
+ self.smile_mac_address = smile_mac_address
85
+ self.smile_model = smile_model
86
+ self.smile_name = smile_name
87
+ self.smile_type = smile_type
88
+ self.smile_version = smile_version
89
+ self.smile_zigbee_mac_address = smile_zigbee_mac_address
90
+
91
+ self._previous_day_number: str = "0"
92
+
93
+ async def full_update_device(self) -> None:
94
+ """Perform a first fetch of all XML data, needed for initialization."""
95
+ self._domain_objects = await self._request(DOMAIN_OBJECTS)
96
+ self._locations = await self._request(LOCATIONS)
97
+ self._modules = await self._request(MODULES)
98
+ # P1 legacy has no appliances
99
+ if self.smile_type != "power":
100
+ self._appliances = await self._request(APPLIANCES)
101
+
102
+ def get_all_devices(self) -> None:
103
+ """Determine the evices present from the obtained XML-data.
104
+
105
+ Run this functions once to gather the initial device configuration,
106
+ then regularly run async_update() to refresh the device data.
107
+ """
108
+ # Gather all the devices and their initial data
109
+ self._all_appliances()
110
+
111
+ # Collect and add switching- and/or pump-group devices
112
+ if group_data := self._get_group_switches():
113
+ self.gw_devices.update(group_data)
114
+
115
+ # Collect the remaining data for all devices
116
+ self._all_device_data()
117
+
118
+ async def async_update(self) -> PlugwiseData:
119
+ """Perform an incremental update for updating the various device states."""
120
+ # Perform a full update at day-change
121
+ day_number = dt.datetime.now().strftime("%w")
122
+ if (
123
+ day_number # pylint: disable=consider-using-assignment-expr
124
+ != self._previous_day_number
125
+ ):
126
+ LOGGER.info(
127
+ "Performing daily full-update, reload the Plugwise integration when a single entity becomes unavailable."
128
+ )
129
+ self.gw_data: GatewayData = {}
130
+ self.gw_devices: dict[str, DeviceData] = {}
131
+ await self.full_update_device()
132
+ self.get_all_devices()
133
+ # Otherwise perform an incremental update
134
+ else:
135
+ self._domain_objects = await self._request(DOMAIN_OBJECTS)
136
+ match self._target_smile:
137
+ case "smile_v2":
138
+ self._modules = await self._request(MODULES)
139
+ case self._target_smile if self._target_smile in REQUIRE_APPLIANCES:
140
+ self._appliances = await self._request(APPLIANCES)
141
+
142
+ self._update_gw_devices()
143
+
144
+ self._previous_day_number = day_number
145
+ return PlugwiseData(self.gw_data, self.gw_devices)
146
+
147
+ ########################################################################################################
148
+ ### API Set and HA Service-related Functions ###
149
+ ########################################################################################################
150
+
151
+ async def set_schedule_state(self, _: str, state: str, __: str | None) -> None:
152
+ """Activate/deactivate the Schedule.
153
+
154
+ Determined from - DOMAIN_OBJECTS.
155
+ Used in HA Core to set the hvac_mode: in practice switch between schedule on - off.
156
+ """
157
+ if state not in ["on", "off"]:
158
+ raise PlugwiseError("Plugwise: invalid schedule state.")
159
+
160
+ name = "Thermostat schedule"
161
+ schedule_rule_id: str | None = None
162
+ for rule in self._domain_objects.findall("rule"):
163
+ if rule.find("name").text == name:
164
+ schedule_rule_id = rule.attrib["id"]
165
+
166
+ if schedule_rule_id is None:
167
+ raise PlugwiseError("Plugwise: no schedule with this name available.") # pragma: no cover
168
+
169
+ new_state = "false"
170
+ if state == "on":
171
+ new_state = "true"
172
+
173
+ locator = f'.//*[@id="{schedule_rule_id}"]/template'
174
+ for rule in self._domain_objects.findall(locator):
175
+ template_id = rule.attrib["id"]
176
+
177
+ uri = f"{RULES};id={schedule_rule_id}"
178
+ data = (
179
+ "<rules><rule"
180
+ f' id="{schedule_rule_id}"><name><![CDATA[{name}]]></name><template'
181
+ f' id="{template_id}" /><active>{new_state}</active></rule></rules>'
182
+ )
183
+
184
+ await self._request(uri, method="put", data=data)
185
+
186
+ async def set_preset(self, _: str, preset: str) -> None:
187
+ """Set the given Preset on the relevant Thermostat - from DOMAIN_OBJECTS."""
188
+ if (presets := self._presets()) is None:
189
+ raise PlugwiseError("Plugwise: no presets available.") # pragma: no cover
190
+ if preset not in list(presets):
191
+ raise PlugwiseError("Plugwise: invalid preset.")
192
+
193
+ locator = f'rule/directives/when/then[@icon="{preset}"].../.../...'
194
+ rule = self._domain_objects.find(locator)
195
+ data = f'<rules><rule id="{rule.attrib["id"]}"><active>true</active></rule></rules>'
196
+
197
+ await self._request(RULES, method="put", data=data)
198
+
199
+ async def set_temperature(self, setpoint: str, _: dict[str, float]) -> None:
200
+ """Set the given Temperature on the relevant Thermostat."""
201
+ if setpoint is None:
202
+ raise PlugwiseError(
203
+ "Plugwise: failed setting temperature: no valid input provided"
204
+ ) # pragma: no cover"
205
+
206
+ temperature = str(setpoint)
207
+ uri = self._thermostat_uri()
208
+ data = (
209
+ "<thermostat_functionality><setpoint>"
210
+ f"{temperature}</setpoint></thermostat_functionality>"
211
+ )
212
+
213
+ await self._request(uri, method="put", data=data)
214
+
215
+ async def set_switch_state(
216
+ self, appl_id: str, members: list[str] | None, model: str, state: str
217
+ ) -> None:
218
+ """Set the given State of the relevant Switch."""
219
+ switch = Munch()
220
+ switch.actuator = "actuator_functionalities"
221
+ switch.func_type = "relay_functionality"
222
+ if self._stretch_v2:
223
+ switch.actuator = "actuators"
224
+ switch.func_type = "relay"
225
+ switch.func = "state"
226
+
227
+ if members is not None:
228
+ return await self._set_groupswitch_member_state(members, state, switch)
229
+
230
+ data = f"<{switch.func_type}><{switch.func}>{state}</{switch.func}></{switch.func_type}>"
231
+ uri = f"{APPLIANCES};id={appl_id}/{switch.func_type}"
232
+
233
+ if model == "relay":
234
+ locator = (
235
+ f'appliance[@id="{appl_id}"]/{switch.actuator}/{switch.func_type}/lock'
236
+ )
237
+ # Don't bother switching a relay when the corresponding lock-state is true
238
+ if self._appliances.find(locator).text == "true":
239
+ raise PlugwiseError("Plugwise: the locked Relay was not switched.")
240
+
241
+ await self._request(uri, method="put", data=data)
242
+
243
+ async def _set_groupswitch_member_state(
244
+ self, members: list[str], state: str, switch: Munch
245
+ ) -> None:
246
+ """Helper-function for set_switch_state().
247
+
248
+ Set the given State of the relevant Switch within a group of members.
249
+ """
250
+ for member in members:
251
+ uri = f"{APPLIANCES};id={member}/{switch.func_type}"
252
+ data = f"<{switch.func_type}><{switch.func}>{state}</{switch.func}></{switch.func_type}>"
253
+
254
+ await self._request(uri, method="put", data=data)
255
+
256
+ async def set_number_setpoint(self, key: str, temperature: float) -> None:
257
+ """Set-function placeholder for legacy devices."""
258
+
259
+ async def set_temperature_offset(self, dev_id: str, offset: float) -> None:
260
+ """Set-function placeholder for legacy devices."""
261
+
262
+ async def set_gateway_mode(self, mode: str) -> None:
263
+ """Set-function placeholder for legacy devices."""
264
+
265
+ async def set_regulation_mode(self, mode: str) -> None:
266
+ """Set-function placeholder for legacy devices."""
267
+
268
+ async def set_dhw_mode(self, mode: str) -> None:
269
+ """Set-function placeholder for legacy devices."""
270
+
271
+ async def delete_notification(self) -> None:
272
+ """Set-function placeholder for legacy devices."""
plugwise/smile.py ADDED
@@ -0,0 +1,426 @@
1
+ """Use of this source code is governed by the MIT license found in the LICENSE file.
2
+
3
+ Plugwise backend module for Home Assistant Core.
4
+ """
5
+ from __future__ import annotations
6
+
7
+ import datetime as dt
8
+
9
+ from plugwise.constants import (
10
+ ADAM,
11
+ ANNA,
12
+ APPLIANCES,
13
+ DEFAULT_PORT,
14
+ DEFAULT_TIMEOUT,
15
+ DEFAULT_USERNAME,
16
+ DOMAIN_OBJECTS,
17
+ LOCATIONS,
18
+ MAX_SETPOINT,
19
+ MIN_SETPOINT,
20
+ NOTIFICATIONS,
21
+ OFF,
22
+ RULES,
23
+ DeviceData,
24
+ GatewayData,
25
+ PlugwiseData,
26
+ ThermoLoc,
27
+ )
28
+ from plugwise.data import SmileData
29
+ from plugwise.exceptions import PlugwiseError
30
+ from plugwise.helper import SmileComm
31
+
32
+ import aiohttp
33
+ from defusedxml import ElementTree as etree
34
+
35
+ # Dict as class
36
+ from munch import Munch
37
+
38
+ # Version detection
39
+ import semver
40
+
41
+
42
+ class SmileAPI(SmileComm, SmileData):
43
+ """The Plugwise SmileAPI helper class for actual Plugwise devices."""
44
+
45
+ # pylint: disable=too-many-instance-attributes, too-many-public-methods
46
+
47
+ def __init__(
48
+ self,
49
+ host: str,
50
+ password: str,
51
+ websession: aiohttp.ClientSession,
52
+ _cooling_present: bool,
53
+ _elga: bool,
54
+ _is_thermostat: bool,
55
+ _last_active: dict[str, str | None],
56
+ _on_off_device: bool,
57
+ _opentherm_device: bool,
58
+ _schedule_old_states: dict[str, dict[str, str]],
59
+ gateway_id: str,
60
+ loc_data: dict[str, ThermoLoc],
61
+ smile_fw_version: str | None,
62
+ smile_hostname: str | None,
63
+ smile_hw_version: str | None,
64
+ smile_legacy: bool,
65
+ smile_mac_address: str | None,
66
+ smile_model: str,
67
+ smile_name: str,
68
+ smile_type: str,
69
+ smile_version: tuple[str, semver.version.Version],
70
+ username: str = DEFAULT_USERNAME,
71
+ port: int = DEFAULT_PORT,
72
+ timeout: float = DEFAULT_TIMEOUT,
73
+
74
+ ) -> None:
75
+ """Set the constructor for this class."""
76
+ super().__init__(
77
+ host,
78
+ password,
79
+ websession,
80
+ username,
81
+ port,
82
+ timeout,
83
+ )
84
+ SmileData.__init__(self)
85
+
86
+ self._cooling_present = _cooling_present
87
+ self._elga = _elga
88
+ self._is_thermostat = _is_thermostat
89
+ self._last_active = _last_active
90
+ self._on_off_device = _on_off_device
91
+ self._opentherm_device = _opentherm_device
92
+ self._schedule_old_states = _schedule_old_states
93
+ self.gateway_id = gateway_id
94
+ self.loc_data = loc_data
95
+ self.smile_fw_version = smile_fw_version
96
+ self.smile_hostname = smile_hostname
97
+ self.smile_hw_version = smile_hw_version
98
+ self.smile_legacy = smile_legacy
99
+ self.smile_mac_address = smile_mac_address
100
+ self.smile_model = smile_model
101
+ self.smile_name = smile_name
102
+ self.smile_type = smile_type
103
+ self.smile_version = smile_version
104
+
105
+ self._heater_id: str
106
+ self._cooling_enabled = False
107
+
108
+ async def full_update_device(self) -> None:
109
+ """Perform a first fetch of all XML data, needed for initialization."""
110
+ self._domain_objects = await self._request(DOMAIN_OBJECTS)
111
+ self._get_plugwise_notifications()
112
+
113
+ def get_all_devices(self) -> None:
114
+ """Determine the evices present from the obtained XML-data.
115
+
116
+ Run this functions once to gather the initial device configuration,
117
+ then regularly run async_update() to refresh the device data.
118
+ """
119
+ # Gather all the devices and their initial data
120
+ self._all_appliances()
121
+ if self._is_thermostat:
122
+ if self.smile(ADAM):
123
+ self._scan_thermostats()
124
+ # Collect a list of thermostats with offset-capability
125
+ self.therms_with_offset_func = (
126
+ self._get_appliances_with_offset_functionality()
127
+ )
128
+
129
+ # Collect and add switching- and/or pump-group devices
130
+ if group_data := self._get_group_switches():
131
+ self.gw_devices.update(group_data)
132
+
133
+ # Collect the remaining data for all devices
134
+ self._all_device_data()
135
+
136
+ async def async_update(self) -> PlugwiseData:
137
+ """Perform an incremental update for updating the various device states."""
138
+ # Perform a full update at day-change
139
+ self.gw_data: GatewayData = {}
140
+ self.gw_devices: dict[str, DeviceData] = {}
141
+ await self.full_update_device()
142
+ self.get_all_devices()
143
+
144
+ if "heater_id" in self.gw_data:
145
+ self._heater_id = self.gw_data["heater_id"]
146
+ if "cooling_enabled" in self.gw_devices[self._heater_id]["binary_sensors"]:
147
+ self._cooling_enabled = self.gw_devices[self._heater_id]["binary_sensors"]["cooling_enabled"]
148
+
149
+ return PlugwiseData(self.gw_data, self.gw_devices)
150
+
151
+ ########################################################################################################
152
+ ### API Set and HA Service-related Functions ###
153
+ ########################################################################################################
154
+
155
+ async def set_schedule_state(
156
+ self,
157
+ loc_id: str,
158
+ new_state: str,
159
+ name: str | None = None,
160
+ ) -> None:
161
+ """Activate/deactivate the Schedule, with the given name, on the relevant Thermostat.
162
+
163
+ Determined from - DOMAIN_OBJECTS.
164
+ Used in HA Core to set the hvac_mode: in practice switch between schedule on - off.
165
+ """
166
+ # Input checking
167
+ if new_state not in ["on", "off"]:
168
+ raise PlugwiseError("Plugwise: invalid schedule state.")
169
+
170
+ # Translate selection of Off-schedule-option to disabling the active schedule
171
+ if name == OFF:
172
+ new_state = "off"
173
+
174
+ # Handle no schedule-name / Off-schedule provided
175
+ if name is None or name == OFF:
176
+ if schedule_name := self._last_active[loc_id]:
177
+ name = schedule_name
178
+ else:
179
+ return
180
+
181
+ assert isinstance(name, str)
182
+ schedule_rule = self._rule_ids_by_name(name, loc_id)
183
+ # Raise an error when the schedule name does not exist
184
+ if not schedule_rule or schedule_rule is None:
185
+ raise PlugwiseError("Plugwise: no schedule with this name available.")
186
+
187
+ # If no state change is requested, do nothing
188
+ if new_state == self._schedule_old_states[loc_id][name]:
189
+ return
190
+
191
+ schedule_rule_id: str = next(iter(schedule_rule))
192
+ template = (
193
+ '<template tag="zone_preset_based_on_time_and_presence_with_override" />'
194
+ )
195
+ if self.smile(ANNA):
196
+ locator = f'.//*[@id="{schedule_rule_id}"]/template'
197
+ template_id = self._domain_objects.find(locator).attrib["id"]
198
+ template = f'<template id="{template_id}" />'
199
+
200
+ contexts = self.determine_contexts(loc_id, name, new_state, schedule_rule_id)
201
+ uri = f"{RULES};id={schedule_rule_id}"
202
+ data = (
203
+ f'<rules><rule id="{schedule_rule_id}"><name><![CDATA[{name}]]></name>'
204
+ f"{template}{contexts}</rule></rules>"
205
+ )
206
+
207
+ await self._request(uri, method="put", data=data)
208
+ self._schedule_old_states[loc_id][name] = new_state
209
+
210
+ def determine_contexts(
211
+ self, loc_id: str, name: str, state: str, sched_id: str
212
+ ) -> etree:
213
+ """Helper-function for set_schedule_state()."""
214
+ locator = f'.//*[@id="{sched_id}"]/contexts'
215
+ contexts = self._domain_objects.find(locator)
216
+ locator = f'.//*[@id="{loc_id}"].../...'
217
+ if (subject := contexts.find(locator)) is None:
218
+ subject = f'<context><zone><location id="{loc_id}" /></zone></context>'
219
+ subject = etree.fromstring(subject)
220
+
221
+ if state == "off":
222
+ self._last_active[loc_id] = name
223
+ contexts.remove(subject)
224
+ if state == "on":
225
+ contexts.append(subject)
226
+
227
+ return etree.tostring(contexts, encoding="unicode").rstrip()
228
+
229
+ async def set_preset(self, loc_id: str, preset: str) -> None:
230
+ """Set the given Preset on the relevant Thermostat - from LOCATIONS."""
231
+ if (presets := self._presets(loc_id)) is None:
232
+ raise PlugwiseError("Plugwise: no presets available.") # pragma: no cover
233
+ if preset not in list(presets):
234
+ raise PlugwiseError("Plugwise: invalid preset.")
235
+
236
+ current_location = self._domain_objects.find(f'location[@id="{loc_id}"]')
237
+ location_name = current_location.find("name").text
238
+ location_type = current_location.find("type").text
239
+
240
+ uri = f"{LOCATIONS};id={loc_id}"
241
+ data = (
242
+ "<locations><location"
243
+ f' id="{loc_id}"><name>{location_name}</name><type>{location_type}'
244
+ f"</type><preset>{preset}</preset></location></locations>"
245
+ )
246
+
247
+ await self._request(uri, method="put", data=data)
248
+
249
+ async def set_temperature(self, loc_id: str, items: dict[str, float]) -> None:
250
+ """Set the given Temperature on the relevant Thermostat."""
251
+ setpoint: float | None = None
252
+
253
+ if "setpoint" in items:
254
+ setpoint = items["setpoint"]
255
+
256
+ if self.smile(ANNA) and self._cooling_present:
257
+ if "setpoint_high" not in items:
258
+ raise PlugwiseError
259
+ tmp_setpoint_high = items["setpoint_high"]
260
+ tmp_setpoint_low = items["setpoint_low"]
261
+ if self._cooling_enabled: # in cooling mode
262
+ setpoint = tmp_setpoint_high
263
+ if tmp_setpoint_low != MIN_SETPOINT:
264
+ raise PlugwiseError(
265
+ "Plugwise: heating setpoint cannot be changed when in cooling mode"
266
+ )
267
+ else: # in heating mode
268
+ setpoint = tmp_setpoint_low
269
+ if tmp_setpoint_high != MAX_SETPOINT:
270
+ raise PlugwiseError(
271
+ "Plugwise: cooling setpoint cannot be changed when in heating mode"
272
+ )
273
+
274
+ if setpoint is None:
275
+ raise PlugwiseError # pragma: no cover"
276
+
277
+ temperature = str(setpoint)
278
+ uri = self._thermostat_uri(loc_id)
279
+ data = (
280
+ "<thermostat_functionality><setpoint>"
281
+ f"{temperature}</setpoint></thermostat_functionality>"
282
+ )
283
+
284
+ await self._request(uri, method="put", data=data)
285
+
286
+ async def set_number_setpoint(self, key: str, temperature: float) -> None:
287
+ """Set the max. Boiler or DHW setpoint on the Central Heating boiler."""
288
+ temp = str(temperature)
289
+ thermostat_id: str | None = None
290
+ locator = f'appliance[@id="{self._heater_id}"]/actuator_functionalities/thermostat_functionality'
291
+ if th_func_list := self._domain_objects.findall(locator):
292
+ for th_func in th_func_list:
293
+ if th_func.find("type").text == key:
294
+ thermostat_id = th_func.attrib["id"]
295
+
296
+ if thermostat_id is None:
297
+ raise PlugwiseError
298
+
299
+ uri = f"{APPLIANCES};id={self._heater_id}/thermostat;id={thermostat_id}"
300
+ data = f"<thermostat_functionality><setpoint>{temp}</setpoint></thermostat_functionality>"
301
+ await self._request(uri, method="put", data=data)
302
+
303
+ async def set_temperature_offset(self, dev_id: str, offset: float) -> None:
304
+ """Set the Temperature offset for thermostats that support this feature."""
305
+ if dev_id not in self.therms_with_offset_func:
306
+ raise PlugwiseError
307
+
308
+ value = str(offset)
309
+ uri = f"{APPLIANCES};id={dev_id}/offset;type=temperature_offset"
310
+ data = f"<offset_functionality><offset>{value}</offset></offset_functionality>"
311
+
312
+ await self._request(uri, method="put", data=data)
313
+
314
+ async def set_switch_state(
315
+ self, appl_id: str, members: list[str] | None, model: str, state: str
316
+ ) -> None:
317
+ """Set the given State of the relevant Switch."""
318
+ switch = Munch()
319
+ switch.actuator = "actuator_functionalities"
320
+ switch.device = "relay"
321
+ switch.func_type = "relay_functionality"
322
+ switch.func = "state"
323
+ if model == "dhw_cm_switch":
324
+ switch.device = "toggle"
325
+ switch.func_type = "toggle_functionality"
326
+ switch.act_type = "domestic_hot_water_comfort_mode"
327
+
328
+ if model == "cooling_ena_switch":
329
+ switch.device = "toggle"
330
+ switch.func_type = "toggle_functionality"
331
+ switch.act_type = "cooling_enabled"
332
+
333
+ if model == "lock":
334
+ switch.func = "lock"
335
+ state = "false" if state == "off" else "true"
336
+
337
+ if members is not None:
338
+ return await self._set_groupswitch_member_state(members, state, switch)
339
+
340
+ locator = f'appliance[@id="{appl_id}"]/{switch.actuator}/{switch.func_type}'
341
+ found: list[etree] = self._domain_objects.findall(locator)
342
+ for item in found:
343
+ if (sw_type := item.find("type")) is not None:
344
+ if sw_type.text == switch.act_type:
345
+ switch_id = item.attrib["id"]
346
+ else:
347
+ switch_id = item.attrib["id"]
348
+ break
349
+
350
+ uri = f"{APPLIANCES};id={appl_id}/{switch.device};id={switch_id}"
351
+ data = f"<{switch.func_type}><{switch.func}>{state}</{switch.func}></{switch.func_type}>"
352
+
353
+ if model == "relay":
354
+ locator = (
355
+ f'appliance[@id="{appl_id}"]/{switch.actuator}/{switch.func_type}/lock'
356
+ )
357
+ # Don't bother switching a relay when the corresponding lock-state is true
358
+ if self._domain_objects.find(locator).text == "true":
359
+ raise PlugwiseError
360
+
361
+ await self._request(uri, method="put", data=data)
362
+
363
+ async def _set_groupswitch_member_state(
364
+ self, members: list[str], state: str, switch: Munch
365
+ ) -> None:
366
+ """Helper-function for set_switch_state().
367
+
368
+ Set the given State of the relevant Switch within a group of members.
369
+ """
370
+ for member in members:
371
+ locator = f'appliance[@id="{member}"]/{switch.actuator}/{switch.func_type}'
372
+ switch_id = self._domain_objects.find(locator).attrib["id"]
373
+ uri = f"{APPLIANCES};id={member}/{switch.device};id={switch_id}"
374
+ data = f"<{switch.func_type}><{switch.func}>{state}</{switch.func}></{switch.func_type}>"
375
+
376
+ await self._request(uri, method="put", data=data)
377
+
378
+ async def set_gateway_mode(self, mode: str) -> None:
379
+ """Set the gateway mode."""
380
+ if mode not in self._gw_allowed_modes:
381
+ raise PlugwiseError
382
+
383
+ end_time = "2037-04-21T08:00:53.000Z"
384
+ valid = ""
385
+ if mode == "away":
386
+ time_1 = self._domain_objects.find("./gateway/time").text
387
+ away_time = dt.datetime.fromisoformat(time_1).astimezone(dt.UTC).isoformat(timespec="milliseconds").replace("+00:00", "Z")
388
+ valid = (
389
+ f"<valid_from>{away_time}</valid_from><valid_to>{end_time}</valid_to>"
390
+ )
391
+ if mode == "vacation":
392
+ time_2 = str(dt.date.today() - dt.timedelta(1))
393
+ vacation_time = time_2 + "T23:00:00.000Z"
394
+ valid = f"<valid_from>{vacation_time}</valid_from><valid_to>{end_time}</valid_to>"
395
+
396
+ uri = f"{APPLIANCES};id={self.gateway_id}/gateway_mode_control"
397
+ data = f"<gateway_mode_control_functionality><mode>{mode}</mode>{valid}</gateway_mode_control_functionality>"
398
+
399
+ await self._request(uri, method="put", data=data)
400
+
401
+ async def set_regulation_mode(self, mode: str) -> None:
402
+ """Set the heating regulation mode."""
403
+ if mode not in self._reg_allowed_modes:
404
+ raise PlugwiseError
405
+
406
+ uri = f"{APPLIANCES};type=gateway/regulation_mode_control"
407
+ duration = ""
408
+ if "bleeding" in mode:
409
+ duration = "<duration>300</duration>"
410
+ data = f"<regulation_mode_control_functionality>{duration}<mode>{mode}</mode></regulation_mode_control_functionality>"
411
+
412
+ await self._request(uri, method="put", data=data)
413
+
414
+ async def set_dhw_mode(self, mode: str) -> None:
415
+ """Set the domestic hot water heating regulation mode."""
416
+ if mode not in self._dhw_allowed_modes:
417
+ raise PlugwiseError("Plugwise: invalid dhw mode.")
418
+
419
+ uri = f"{APPLIANCES};type=heater_central/domestic_hot_water_mode_control"
420
+ data = f"<domestic_hot_water_mode_control_functionality><mode>{mode}</mode></domestic_hot_water_mode_control_functionality>"
421
+
422
+ await self._request(uri, method="put", data=data)
423
+
424
+ async def delete_notification(self) -> None:
425
+ """Delete the active Plugwise Notification."""
426
+ await self._request(NOTIFICATIONS, method="delete")