plugwise 0.35.4__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.
plugwise/__init__.py ADDED
@@ -0,0 +1,890 @@
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
+ import aiohttp
10
+ from defusedxml import ElementTree as etree
11
+
12
+ # Dict as class
13
+ from munch import Munch
14
+
15
+ # Version detection
16
+ import semver
17
+
18
+ from .constants import (
19
+ ADAM,
20
+ ANNA,
21
+ APPLIANCES,
22
+ DEFAULT_PORT,
23
+ DEFAULT_TIMEOUT,
24
+ DEFAULT_USERNAME,
25
+ DOMAIN_OBJECTS,
26
+ LOCATIONS,
27
+ LOGGER,
28
+ MAX_SETPOINT,
29
+ MIN_SETPOINT,
30
+ MODULES,
31
+ NONE,
32
+ NOTIFICATIONS,
33
+ OFF,
34
+ REQUIRE_APPLIANCES,
35
+ RULES,
36
+ SMILES,
37
+ STATUS,
38
+ SWITCH_GROUP_TYPES,
39
+ SYSTEM,
40
+ ZONE_THERMOSTATS,
41
+ ActuatorData,
42
+ DeviceData,
43
+ GatewayData,
44
+ PlugwiseData,
45
+ )
46
+ from .exceptions import (
47
+ InvalidSetupError,
48
+ PlugwiseError,
49
+ ResponseError,
50
+ UnsupportedDeviceError,
51
+ )
52
+ from .helper import SmileComm, SmileHelper
53
+
54
+
55
+ def remove_empty_platform_dicts(data: DeviceData) -> None:
56
+ """Helper-function for removing any empty platform dicts."""
57
+ if not data["binary_sensors"]:
58
+ data.pop("binary_sensors")
59
+ if not data["sensors"]:
60
+ data.pop("sensors")
61
+ if not data["switches"]:
62
+ data.pop("switches")
63
+
64
+
65
+ class SmileData(SmileHelper):
66
+ """The Plugwise Smile main class."""
67
+
68
+ def _update_gw_devices(self) -> None:
69
+ """Helper-function for _all_device_data() and async_update().
70
+
71
+ Collect data for each device and add to self.gw_devices.
72
+ """
73
+ for device_id, device in self.gw_devices.items():
74
+ data = self._get_device_data(device_id)
75
+ self._add_or_update_notifications(device_id, device, data)
76
+ device.update(data)
77
+ self._update_for_cooling(device)
78
+ remove_empty_platform_dicts(device)
79
+
80
+ def _add_or_update_notifications(
81
+ self, device_id: str, device: DeviceData, data: DeviceData
82
+ ) -> None:
83
+ """Helper-function adding or updating the Plugwise notifications."""
84
+ if (
85
+ device_id == self.gateway_id
86
+ and (
87
+ self._is_thermostat
88
+ or (self.smile_type == "power" and not self._smile_legacy)
89
+ )
90
+ ) or (
91
+ "binary_sensors" in device
92
+ and "plugwise_notification" in device["binary_sensors"]
93
+ ):
94
+ data["binary_sensors"]["plugwise_notification"] = bool(self._notifications)
95
+ self._count += 1
96
+
97
+ def _update_for_cooling(self, device: DeviceData) -> None:
98
+ """Helper-function for adding/updating various cooling-related values."""
99
+ # For Anna and heating + cooling, replace setpoint with setpoint_high/_low
100
+ if (
101
+ self.smile(ANNA)
102
+ and self._cooling_present
103
+ and device["dev_class"] == "thermostat"
104
+ ):
105
+ thermostat = device["thermostat"]
106
+ sensors = device["sensors"]
107
+ temp_dict: ActuatorData = {
108
+ "setpoint_low": thermostat["setpoint"],
109
+ "setpoint_high": MAX_SETPOINT,
110
+ }
111
+ if self._cooling_enabled:
112
+ temp_dict = {
113
+ "setpoint_low": MIN_SETPOINT,
114
+ "setpoint_high": thermostat["setpoint"],
115
+ }
116
+ thermostat.pop("setpoint")
117
+ temp_dict.update(thermostat)
118
+ device["thermostat"] = temp_dict
119
+ if "setpoint" in sensors:
120
+ sensors.pop("setpoint")
121
+ sensors["setpoint_low"] = temp_dict["setpoint_low"]
122
+ sensors["setpoint_high"] = temp_dict["setpoint_high"]
123
+ self._count += 2
124
+
125
+ def get_all_devices(self) -> None:
126
+ """Determine the evices present from the obtained XML-data.
127
+
128
+ Run this functions once to gather the initial device configuration,
129
+ then regularly run async_update() to refresh the device data.
130
+ """
131
+ # Gather all the devices and their initial data
132
+ self._all_appliances()
133
+ if self._is_thermostat:
134
+ self._scan_thermostats()
135
+ # Collect a list of thermostats with offset-capability
136
+ self.therms_with_offset_func = (
137
+ self._get_appliances_with_offset_functionality()
138
+ )
139
+
140
+ # Collect and add switching- and/or pump-group devices
141
+ if group_data := self._get_group_switches():
142
+ self.gw_devices.update(group_data)
143
+
144
+ # Collect the remaining data for all devices
145
+ self._all_device_data()
146
+
147
+ def _all_device_data(self) -> None:
148
+ """Helper-function for get_all_devices().
149
+
150
+ Collect data for each device and add to self.gw_data and self.gw_devices.
151
+ """
152
+ self._update_gw_devices()
153
+ self.device_items = self._count
154
+ self.device_list = []
155
+ for device in self.gw_devices:
156
+ self.device_list.append(device)
157
+
158
+ self.gw_data.update(
159
+ {
160
+ "gateway_id": self.gateway_id,
161
+ "item_count": self._count,
162
+ "notifications": self._notifications,
163
+ "smile_name": self.smile_name,
164
+ }
165
+ )
166
+ if self._is_thermostat:
167
+ self.gw_data.update(
168
+ {"heater_id": self._heater_id, "cooling_present": self._cooling_present}
169
+ )
170
+
171
+ def _device_data_switching_group(
172
+ self, device: DeviceData, data: DeviceData
173
+ ) -> None:
174
+ """Helper-function for _get_device_data().
175
+
176
+ Determine switching group device data.
177
+ """
178
+ if device["dev_class"] in SWITCH_GROUP_TYPES:
179
+ counter = 0
180
+ for member in device["members"]:
181
+ if self.gw_devices[member]["switches"].get("relay"):
182
+ counter += 1
183
+ data["switches"]["relay"] = counter != 0
184
+ self._count += 1
185
+
186
+ def _device_data_adam(self, device: DeviceData, data: DeviceData) -> None:
187
+ """Helper-function for _get_device_data().
188
+
189
+ Determine Adam heating-status for on-off heating via valves,
190
+ available regulations_modes and thermostat control_states.
191
+ """
192
+ if self.smile(ADAM):
193
+ # Indicate heating_state based on valves being open in case of city-provided heating
194
+ if (
195
+ device["dev_class"] == "heater_central"
196
+ and self._on_off_device
197
+ and isinstance(self._heating_valves(), int)
198
+ ):
199
+ data["binary_sensors"]["heating_state"] = self._heating_valves() != 0
200
+
201
+ # Show the allowed regulation modes for Adam
202
+ if device["dev_class"] == "gateway" and self._reg_allowed_modes:
203
+ data["regulation_modes"] = self._reg_allowed_modes
204
+ self._count += 1
205
+
206
+ # Control_state, only for Adam master thermostats
207
+ if device["dev_class"] in ZONE_THERMOSTATS:
208
+ loc_id = device["location"]
209
+ if ctrl_state := self._control_state(loc_id):
210
+ data["control_state"] = ctrl_state
211
+ self._count += 1
212
+
213
+ def _device_data_climate(self, device: DeviceData, data: DeviceData) -> None:
214
+ """Helper-function for _get_device_data().
215
+
216
+ Determine climate-control device data.
217
+ """
218
+ loc_id = device["location"]
219
+
220
+ # Presets
221
+ data["preset_modes"] = None
222
+ data["active_preset"] = None
223
+ self._count += 2
224
+ if presets := self._presets(loc_id):
225
+ data["preset_modes"] = list(presets)
226
+ data["active_preset"] = self._preset(loc_id)
227
+
228
+ # Schedule
229
+ avail_schedules, sel_schedule = self._schedules(loc_id)
230
+ data["available_schedules"] = avail_schedules
231
+ data["select_schedule"] = sel_schedule
232
+ self._count += 2
233
+
234
+ # Operation modes: auto, heat, heat_cool, cool and off
235
+ data["mode"] = "auto"
236
+ self._count += 1
237
+ if sel_schedule == NONE:
238
+ data["mode"] = "heat"
239
+ if self._cooling_present:
240
+ data["mode"] = "cool" if self.check_reg_mode("cooling") else "heat_cool"
241
+
242
+ if self.check_reg_mode("off"):
243
+ data["mode"] = "off"
244
+
245
+ if NONE not in avail_schedules:
246
+ self._get_schedule_states_with_off(
247
+ loc_id, avail_schedules, sel_schedule, data
248
+ )
249
+
250
+ def check_reg_mode(self, mode: str) -> bool:
251
+ """Helper-function for device_data_climate()."""
252
+ gateway = self.gw_devices[self.gateway_id]
253
+ return (
254
+ "regulation_modes" in gateway and gateway["select_regulation_mode"] == mode
255
+ )
256
+
257
+ def _get_schedule_states_with_off(
258
+ self, location: str, schedules: list[str], selected: str, data: DeviceData
259
+ ) -> None:
260
+ """Collect schedules with states for each thermostat.
261
+
262
+ Also, replace NONE by OFF when none of the schedules are active,
263
+ only for non-legacy thermostats.
264
+ """
265
+ loc_schedule_states: dict[str, str] = {}
266
+ for schedule in schedules:
267
+ loc_schedule_states[schedule] = "off"
268
+ if schedule == selected and data["mode"] == "auto":
269
+ loc_schedule_states[schedule] = "on"
270
+ self._schedule_old_states[location] = loc_schedule_states
271
+
272
+ all_off = True
273
+ if not self._smile_legacy:
274
+ for state in self._schedule_old_states[location].values():
275
+ if state == "on":
276
+ all_off = False
277
+ if all_off:
278
+ data["select_schedule"] = OFF
279
+
280
+ def _check_availability(
281
+ self, device: DeviceData, dev_class: str, data: DeviceData, message: str
282
+ ) -> None:
283
+ """Helper-function for _get_device_data().
284
+
285
+ Provide availability status for the wired-commected devices.
286
+ """
287
+ if device["dev_class"] == dev_class:
288
+ data["available"] = True
289
+ self._count += 1
290
+ for item in self._notifications.values():
291
+ for msg in item.values():
292
+ if message in msg:
293
+ data["available"] = False
294
+
295
+ def _get_device_data(self, dev_id: str) -> DeviceData:
296
+ """Helper-function for _all_device_data() and async_update().
297
+
298
+ Provide device-data, based on Location ID (= dev_id), from APPLIANCES.
299
+ """
300
+ device = self.gw_devices[dev_id]
301
+ data = self._get_measurement_data(dev_id)
302
+
303
+ # Check availability of non-legacy wired-connected devices
304
+ if not self._smile_legacy:
305
+ # Smartmeter
306
+ self._check_availability(
307
+ device, "smartmeter", data, "P1 does not seem to be connected"
308
+ )
309
+ # OpenTherm device
310
+ if device["name"] != "OnOff":
311
+ self._check_availability(
312
+ device, "heater_central", data, "no OpenTherm communication"
313
+ )
314
+
315
+ # Switching groups data
316
+ self._device_data_switching_group(device, data)
317
+ # Adam data
318
+ self._device_data_adam(device, data)
319
+ # Skip obtaining data for non master-thermostats
320
+ if device["dev_class"] not in ZONE_THERMOSTATS:
321
+ return data
322
+
323
+ # Thermostat data (presets, temperatures etc)
324
+ self._device_data_climate(device, data)
325
+
326
+ return data
327
+
328
+
329
+ class Smile(SmileComm, SmileData):
330
+ """The Plugwise SmileConnect class."""
331
+
332
+ # pylint: disable=too-many-instance-attributes, too-many-public-methods
333
+
334
+ def __init__(
335
+ self,
336
+ host: str,
337
+ password: str,
338
+ username: str = DEFAULT_USERNAME,
339
+ port: int = DEFAULT_PORT,
340
+ timeout: float = DEFAULT_TIMEOUT,
341
+ websession: aiohttp.ClientSession | None = None,
342
+ ) -> None:
343
+ """Set the constructor for this class."""
344
+ super().__init__(
345
+ host,
346
+ password,
347
+ username,
348
+ port,
349
+ timeout,
350
+ websession,
351
+ )
352
+ SmileData.__init__(self)
353
+
354
+ self.smile_hostname: str | None = None
355
+ self._previous_day_number: str = "0"
356
+ self._target_smile: str | None = None
357
+
358
+ async def connect(self) -> bool:
359
+ """Connect to Plugwise device and determine its name, type and version."""
360
+ result = await self._request(DOMAIN_OBJECTS)
361
+ # Work-around for Stretch fv 2.7.18
362
+ if not (vendor_names := result.findall("./module/vendor_name")):
363
+ result = await self._request(MODULES)
364
+ vendor_names = result.findall("./module/vendor_name")
365
+
366
+ names: list[str] = []
367
+ for name in vendor_names:
368
+ names.append(name.text)
369
+
370
+ vendor_models = result.findall("./module/vendor_model")
371
+ models: list[str] = []
372
+ for model in vendor_models:
373
+ models.append(model.text)
374
+
375
+ dsmrmain = result.find("./module/protocols/dsmrmain")
376
+ if "Plugwise" not in names and dsmrmain is None: # pragma: no cover
377
+ LOGGER.error(
378
+ "Connected but expected text not returned, we got %s. Please create"
379
+ " an issue on http://github.com/plugwise/python-plugwise",
380
+ result,
381
+ )
382
+ raise ResponseError
383
+
384
+ # Check if Anna is connected to an Adam
385
+ if "159.2" in models:
386
+ LOGGER.error(
387
+ "Your Anna is connected to an Adam, make sure to only add the Adam as integration."
388
+ )
389
+ raise InvalidSetupError
390
+
391
+ # Determine smile specifics
392
+ await self._smile_detect(result, dsmrmain)
393
+
394
+ # Update all endpoints on first connect
395
+ await self._full_update_device()
396
+
397
+ return True
398
+
399
+ async def _smile_detect_legacy(
400
+ self, result: etree, dsmrmain: etree, model: str
401
+ ) -> str:
402
+ """Helper-function for _smile_detect()."""
403
+ # Stretch: find the MAC of the zigbee master_controller (= Stick)
404
+ if network := result.find("./module/protocols/master_controller"):
405
+ self.smile_zigbee_mac_address = network.find("mac_address").text
406
+ # Find the active MAC in case there is an orphaned Stick
407
+ if zb_networks := result.findall("./network"):
408
+ for zb_network in zb_networks:
409
+ if zb_network.find("./nodes/network_router"):
410
+ network = zb_network.find("./master_controller")
411
+ self.smile_zigbee_mac_address = network.find("mac_address").text
412
+
413
+ # Legacy Anna or Stretch:
414
+ if (
415
+ result.find('./appliance[type="thermostat"]') is not None
416
+ or network is not None
417
+ ):
418
+ self._system = await self._request(SYSTEM)
419
+ self.smile_fw_version = self._system.find("./gateway/firmware").text
420
+ model = self._system.find("./gateway/product").text
421
+ self.smile_hostname = self._system.find("./gateway/hostname").text
422
+ # If wlan0 contains data it's active, so eth0 should be checked last
423
+ for network in ("wlan0", "eth0"):
424
+ locator = f"./{network}/mac"
425
+ if (net_locator := self._system.find(locator)) is not None:
426
+ self.smile_mac_address = net_locator.text
427
+ # P1 legacy:
428
+ elif dsmrmain is not None:
429
+ self._status = await self._request(STATUS)
430
+ self.smile_fw_version = self._status.find("./system/version").text
431
+ model = self._status.find("./system/product").text
432
+ self.smile_hostname = self._status.find("./network/hostname").text
433
+ self.smile_mac_address = self._status.find("./network/mac_address").text
434
+
435
+ else: # pragma: no cover
436
+ # No cornercase, just end of the line
437
+ LOGGER.error(
438
+ "Connected but no gateway device information found, please create"
439
+ " an issue on http://github.com/plugwise/python-plugwise"
440
+ )
441
+ raise ResponseError
442
+
443
+ self._smile_legacy = True
444
+ return model
445
+
446
+ async def _smile_detect(self, result: etree, dsmrmain: etree) -> None:
447
+ """Helper-function for connect().
448
+
449
+ Detect which type of Smile is connected.
450
+ """
451
+ model: str = "Unknown"
452
+ if (gateway := result.find("./gateway")) is not None:
453
+ if (v_model := gateway.find("vendor_model")) is not None:
454
+ model = v_model.text
455
+ self.smile_fw_version = gateway.find("firmware_version").text
456
+ self.smile_hw_version = gateway.find("hardware_version").text
457
+ self.smile_hostname = gateway.find("hostname").text
458
+ self.smile_mac_address = gateway.find("mac_address").text
459
+ else:
460
+ model = await self._smile_detect_legacy(result, dsmrmain, model)
461
+
462
+ if model == "Unknown" or self.smile_fw_version is None: # pragma: no cover
463
+ # Corner case check
464
+ LOGGER.error(
465
+ "Unable to find model or version information, please create"
466
+ " an issue on http://github.com/plugwise/python-plugwise"
467
+ )
468
+ raise UnsupportedDeviceError
469
+
470
+ ver = semver.version.Version.parse(self.smile_fw_version)
471
+ self._target_smile = f"{model}_v{ver.major}"
472
+ LOGGER.debug("Plugwise identified as %s", self._target_smile)
473
+ if self._target_smile not in SMILES:
474
+ LOGGER.error(
475
+ "Your Smile identified as %s seems unsupported by our plugin, please"
476
+ " create an issue on http://github.com/plugwise/python-plugwise",
477
+ self._target_smile,
478
+ )
479
+ raise UnsupportedDeviceError
480
+
481
+ if self._target_smile in ("smile_open_therm_v2", "smile_thermo_v3"):
482
+ LOGGER.error(
483
+ "Your Smile identified as %s needs a firmware update as it's firmware is severely outdated",
484
+ self._target_smile,
485
+ ) # pragma: no cover
486
+ raise UnsupportedDeviceError # pragma: no cover
487
+
488
+ self.smile_model = "Gateway"
489
+ self.smile_name = SMILES[self._target_smile].smile_name
490
+ self.smile_type = SMILES[self._target_smile].smile_type
491
+ self.smile_version = (self.smile_fw_version, ver)
492
+
493
+ if self.smile_type == "stretch":
494
+ self._stretch_v2 = self.smile_version[1].major == 2
495
+ self._stretch_v3 = self.smile_version[1].major == 3
496
+
497
+ if self.smile_type == "thermostat":
498
+ self._is_thermostat = True
499
+ # For Adam, Anna, determine the system capabilities:
500
+ # Find the connected heating/cooling device (heater_central),
501
+ # e.g. heat-pump or gas-fired heater
502
+ onoff_boiler: etree = result.find("./module/protocols/onoff_boiler")
503
+ open_therm_boiler: etree = result.find(
504
+ "./module/protocols/open_therm_boiler"
505
+ )
506
+ self._on_off_device = onoff_boiler is not None
507
+ self._opentherm_device = open_therm_boiler is not None
508
+
509
+ # Determine the presence of special features
510
+ locator_1 = "./gateway/features/cooling"
511
+ locator_2 = "./gateway/features/elga_support"
512
+ if result.find(locator_1) is not None:
513
+ self._cooling_present = True
514
+ if result.find(locator_2) is not None:
515
+ self._elga = True
516
+
517
+ async def _full_update_device(self) -> None:
518
+ """Perform a first fetch of all XML data, needed for initialization."""
519
+ self._domain_objects = await self._request(DOMAIN_OBJECTS)
520
+ self._get_plugwise_notifications()
521
+ self._locations = await self._request(LOCATIONS)
522
+ self._modules = await self._request(MODULES)
523
+ # P1 legacy has no appliances
524
+ if not (self.smile_type == "power" and self._smile_legacy):
525
+ self._appliances = await self._request(APPLIANCES)
526
+
527
+ def _get_plugwise_notifications(self) -> None:
528
+ """Collect the Plugwise notifications."""
529
+ self._notifications = {}
530
+ for notification in self._domain_objects.findall("./notification"):
531
+ try:
532
+ msg_id = notification.attrib["id"]
533
+ msg_type = notification.find("type").text
534
+ msg = notification.find("message").text
535
+ self._notifications.update({msg_id: {msg_type: msg}})
536
+ LOGGER.debug("Plugwise notifications: %s", self._notifications)
537
+ except AttributeError: # pragma: no cover
538
+ LOGGER.debug(
539
+ "Plugwise notification present but unable to process, manually investigate: %s",
540
+ f"{self._endpoint}{DOMAIN_OBJECTS}",
541
+ )
542
+
543
+ async def async_update(self) -> PlugwiseData:
544
+ """Perform an incremental update for updating the various device states."""
545
+ # Perform a full update at day-change
546
+ day_number = dt.datetime.now().strftime("%w")
547
+ if (
548
+ day_number # pylint: disable=consider-using-assignment-expr
549
+ != self._previous_day_number
550
+ ):
551
+ LOGGER.debug(
552
+ "Performing daily full-update, reload the Plugwise integration when a single entity becomes unavailable."
553
+ )
554
+ self.gw_data: GatewayData = {}
555
+ self.gw_devices: dict[str, DeviceData] = {}
556
+ await self._full_update_device()
557
+ self.get_all_devices()
558
+ # Otherwise perform an incremental update
559
+ else:
560
+ self._domain_objects = await self._request(DOMAIN_OBJECTS)
561
+ self._get_plugwise_notifications()
562
+ match self._target_smile:
563
+ case "smile_v2":
564
+ self._modules = await self._request(MODULES)
565
+ case "smile_v3" | "smile_v4":
566
+ self._locations = await self._request(LOCATIONS)
567
+ case "smile_open_therm_v2" | "smile_open_therm_v3":
568
+ self._appliances = await self._request(APPLIANCES)
569
+ self._modules = await self._request(MODULES)
570
+ case self._target_smile if self._target_smile in REQUIRE_APPLIANCES:
571
+ self._appliances = await self._request(APPLIANCES)
572
+
573
+ self._update_gw_devices()
574
+ self.gw_data["notifications"] = self._notifications
575
+
576
+ self._previous_day_number = day_number
577
+ return PlugwiseData(self.gw_data, self.gw_devices)
578
+
579
+ async def _set_schedule_state_legacy(
580
+ self, loc_id: str, name: str, status: str
581
+ ) -> None:
582
+ """Helper-function for set_schedule_state()."""
583
+ schedule_rule_id: str | None = None
584
+ for rule in self._domain_objects.findall("rule"):
585
+ if rule.find("name").text == name:
586
+ schedule_rule_id = rule.attrib["id"]
587
+
588
+ if schedule_rule_id is None:
589
+ raise PlugwiseError("Plugwise: no schedule with this name available.")
590
+
591
+ new_state = "false"
592
+ if status == "on":
593
+ new_state = "true"
594
+ # If no state change is requested, do nothing
595
+ if new_state == self._schedule_old_states[loc_id][name]:
596
+ return
597
+
598
+ locator = f'.//*[@id="{schedule_rule_id}"]/template'
599
+ for rule in self._domain_objects.findall(locator):
600
+ template_id = rule.attrib["id"]
601
+
602
+ uri = f"{RULES};id={schedule_rule_id}"
603
+ data = (
604
+ "<rules><rule"
605
+ f' id="{schedule_rule_id}"><name><![CDATA[{name}]]></name><template'
606
+ f' id="{template_id}" /><active>{new_state}</active></rule></rules>'
607
+ )
608
+
609
+ await self._request(uri, method="put", data=data)
610
+ self._schedule_old_states[loc_id][name] = new_state
611
+
612
+ def determine_contexts(
613
+ self, loc_id: str, name: str, state: str, sched_id: str
614
+ ) -> etree:
615
+ """Helper-function for set_schedule_state()."""
616
+ locator = f'.//*[@id="{sched_id}"]/contexts'
617
+ contexts = self._domain_objects.find(locator)
618
+ locator = f'.//*[@id="{loc_id}"].../...'
619
+ if (subject := contexts.find(locator)) is None:
620
+ subject = f'<context><zone><location id="{loc_id}" /></zone></context>'
621
+ subject = etree.fromstring(subject)
622
+
623
+ if state == "off":
624
+ self._last_active[loc_id] = name
625
+ contexts.remove(subject)
626
+ if state == "on":
627
+ contexts.append(subject)
628
+
629
+ return etree.tostring(contexts, encoding="unicode").rstrip()
630
+
631
+ async def set_schedule_state(
632
+ self,
633
+ loc_id: str,
634
+ new_state: str,
635
+ name: str | None = None,
636
+ ) -> None:
637
+ """Activate/deactivate the Schedule, with the given name, on the relevant Thermostat.
638
+
639
+ Determined from - DOMAIN_OBJECTS.
640
+ Used in HA Core to set the hvac_mode: in practice switch between schedule on - off.
641
+ """
642
+ # Input checking
643
+ if new_state not in ["on", "off"]:
644
+ raise PlugwiseError("Plugwise: invalid schedule state.")
645
+
646
+ # Translate selection of Off-schedule-option to disabling the active schedule
647
+ if name == OFF:
648
+ new_state = "off"
649
+
650
+ # Handle no schedule-name / Off-schedule provided
651
+ if name is None or name == OFF:
652
+ if schedule_name := self._last_active[loc_id]:
653
+ name = schedule_name
654
+ else:
655
+ return
656
+
657
+ assert isinstance(name, str)
658
+ if self._smile_legacy:
659
+ await self._set_schedule_state_legacy(loc_id, name, new_state)
660
+ return
661
+
662
+ schedule_rule = self._rule_ids_by_name(name, loc_id)
663
+ # Raise an error when the schedule name does not exist
664
+ if not schedule_rule or schedule_rule is None:
665
+ raise PlugwiseError("Plugwise: no schedule with this name available.")
666
+
667
+ # If no state change is requested, do nothing
668
+ if new_state == self._schedule_old_states[loc_id][name]:
669
+ return
670
+
671
+ schedule_rule_id: str = next(iter(schedule_rule))
672
+ template = (
673
+ '<template tag="zone_preset_based_on_time_and_presence_with_override" />'
674
+ )
675
+ if self.smile(ANNA):
676
+ locator = f'.//*[@id="{schedule_rule_id}"]/template'
677
+ template_id = self._domain_objects.find(locator).attrib["id"]
678
+ template = f'<template id="{template_id}" />'
679
+
680
+ contexts = self.determine_contexts(loc_id, name, new_state, schedule_rule_id)
681
+ uri = f"{RULES};id={schedule_rule_id}"
682
+ data = (
683
+ f'<rules><rule id="{schedule_rule_id}"><name><![CDATA[{name}]]></name>'
684
+ f"{template}{contexts}</rule></rules>"
685
+ )
686
+
687
+ await self._request(uri, method="put", data=data)
688
+ self._schedule_old_states[loc_id][name] = new_state
689
+
690
+ async def _set_preset_legacy(self, preset: str) -> None:
691
+ """Set the given Preset on the relevant Thermostat - from DOMAIN_OBJECTS."""
692
+ locator = f'rule/directives/when/then[@icon="{preset}"].../.../...'
693
+ rule = self._domain_objects.find(locator)
694
+ data = f'<rules><rule id="{rule.attrib["id"]}"><active>true</active></rule></rules>'
695
+
696
+ await self._request(RULES, method="put", data=data)
697
+
698
+ async def set_preset(self, loc_id: str, preset: str) -> None:
699
+ """Set the given Preset on the relevant Thermostat - from LOCATIONS."""
700
+ if (presets := self._presets(loc_id)) is None:
701
+ raise PlugwiseError("Plugwise: no presets available.") # pragma: no cover
702
+ if preset not in list(presets):
703
+ raise PlugwiseError("Plugwise: invalid preset.")
704
+
705
+ if self._smile_legacy:
706
+ await self._set_preset_legacy(preset)
707
+ return
708
+
709
+ current_location = self._locations.find(f'location[@id="{loc_id}"]')
710
+ location_name = current_location.find("name").text
711
+ location_type = current_location.find("type").text
712
+
713
+ uri = f"{LOCATIONS};id={loc_id}"
714
+ data = (
715
+ "<locations><location"
716
+ f' id="{loc_id}"><name>{location_name}</name><type>{location_type}'
717
+ f"</type><preset>{preset}</preset></location></locations>"
718
+ )
719
+
720
+ await self._request(uri, method="put", data=data)
721
+
722
+ async def set_temperature(self, loc_id: str, items: dict[str, float]) -> None:
723
+ """Set the given Temperature on the relevant Thermostat."""
724
+ setpoint: float | None = None
725
+
726
+ if "setpoint" in items:
727
+ setpoint = items["setpoint"]
728
+
729
+ if self.smile(ANNA) and self._cooling_present:
730
+ if "setpoint_high" not in items:
731
+ raise PlugwiseError(
732
+ "Plugwise: failed setting temperature: no valid input provided"
733
+ )
734
+ tmp_setpoint_high = items["setpoint_high"]
735
+ tmp_setpoint_low = items["setpoint_low"]
736
+ if self._cooling_enabled: # in cooling mode
737
+ setpoint = tmp_setpoint_high
738
+ if tmp_setpoint_low != MIN_SETPOINT:
739
+ raise PlugwiseError(
740
+ "Plugwise: heating setpoint cannot be changed when in cooling mode"
741
+ )
742
+ else: # in heating mode
743
+ setpoint = tmp_setpoint_low
744
+ if tmp_setpoint_high != MAX_SETPOINT:
745
+ raise PlugwiseError(
746
+ "Plugwise: cooling setpoint cannot be changed when in heating mode"
747
+ )
748
+
749
+ if setpoint is None:
750
+ raise PlugwiseError(
751
+ "Plugwise: failed setting temperature: no valid input provided"
752
+ ) # pragma: no cover"
753
+
754
+ temperature = str(setpoint)
755
+ uri = self._thermostat_uri(loc_id)
756
+ data = (
757
+ "<thermostat_functionality><setpoint>"
758
+ f"{temperature}</setpoint></thermostat_functionality>"
759
+ )
760
+
761
+ await self._request(uri, method="put", data=data)
762
+
763
+ async def set_number_setpoint(self, key: str, _: str, temperature: float) -> None:
764
+ """Set the max. Boiler or DHW setpoint on the Central Heating boiler."""
765
+ temp = str(temperature)
766
+ thermostat_id: str | None = None
767
+ locator = f'appliance[@id="{self._heater_id}"]/actuator_functionalities/thermostat_functionality'
768
+ if th_func_list := self._appliances.findall(locator):
769
+ for th_func in th_func_list:
770
+ if th_func.find("type").text == key:
771
+ thermostat_id = th_func.attrib["id"]
772
+
773
+ if thermostat_id is None:
774
+ raise PlugwiseError(f"Plugwise: cannot change setpoint, {key} not found.")
775
+
776
+ uri = f"{APPLIANCES};id={self._heater_id}/thermostat;id={thermostat_id}"
777
+ data = f"<thermostat_functionality><setpoint>{temp}</setpoint></thermostat_functionality>"
778
+ await self._request(uri, method="put", data=data)
779
+
780
+ async def set_temperature_offset(self, _: str, dev_id: str, offset: float) -> None:
781
+ """Set the Temperature offset for thermostats that support this feature."""
782
+ if dev_id not in self.therms_with_offset_func:
783
+ raise PlugwiseError(
784
+ "Plugwise: this device does not have temperature-offset capability."
785
+ )
786
+
787
+ value = str(offset)
788
+ uri = f"{APPLIANCES};id={dev_id}/offset;type=temperature_offset"
789
+ data = f"<offset_functionality><offset>{value}</offset></offset_functionality>"
790
+
791
+ await self._request(uri, method="put", data=data)
792
+
793
+ async def _set_groupswitch_member_state(
794
+ self, members: list[str], state: str, switch: Munch
795
+ ) -> None:
796
+ """Helper-function for set_switch_state().
797
+
798
+ Set the given State of the relevant Switch within a group of members.
799
+ """
800
+ for member in members:
801
+ locator = f'appliance[@id="{member}"]/{switch.actuator}/{switch.func_type}'
802
+ switch_id = self._appliances.find(locator).attrib["id"]
803
+ uri = f"{APPLIANCES};id={member}/{switch.device};id={switch_id}"
804
+ if self._stretch_v2:
805
+ uri = f"{APPLIANCES};id={member}/{switch.device}"
806
+ data = f"<{switch.func_type}><{switch.func}>{state}</{switch.func}></{switch.func_type}>"
807
+
808
+ await self._request(uri, method="put", data=data)
809
+
810
+ async def set_switch_state(
811
+ self, appl_id: str, members: list[str] | None, model: str, state: str
812
+ ) -> None:
813
+ """Set the given State of the relevant Switch."""
814
+ switch = Munch()
815
+ switch.actuator = "actuator_functionalities"
816
+ switch.device = "relay"
817
+ switch.func_type = "relay_functionality"
818
+ switch.func = "state"
819
+ if model == "dhw_cm_switch":
820
+ switch.device = "toggle"
821
+ switch.func_type = "toggle_functionality"
822
+ switch.act_type = "domestic_hot_water_comfort_mode"
823
+
824
+ if model == "cooling_ena_switch":
825
+ switch.device = "toggle"
826
+ switch.func_type = "toggle_functionality"
827
+ switch.act_type = "cooling_enabled"
828
+
829
+ if model == "lock":
830
+ switch.func = "lock"
831
+ state = "false" if state == "off" else "true"
832
+
833
+ if self._stretch_v2:
834
+ switch.actuator = "actuators"
835
+ switch.func_type = "relay"
836
+
837
+ if members is not None:
838
+ return await self._set_groupswitch_member_state(members, state, switch)
839
+
840
+ locator = f'appliance[@id="{appl_id}"]/{switch.actuator}/{switch.func_type}'
841
+ found: list[etree] = self._appliances.findall(locator)
842
+ for item in found:
843
+ if (sw_type := item.find("type")) is not None:
844
+ if sw_type.text == switch.act_type:
845
+ switch_id = item.attrib["id"]
846
+ else:
847
+ switch_id = item.attrib["id"]
848
+ break
849
+
850
+ uri = f"{APPLIANCES};id={appl_id}/{switch.device};id={switch_id}"
851
+ if self._stretch_v2:
852
+ uri = f"{APPLIANCES};id={appl_id}/{switch.device}"
853
+ data = f"<{switch.func_type}><{switch.func}>{state}</{switch.func}></{switch.func_type}>"
854
+
855
+ if model == "relay":
856
+ locator = (
857
+ f'appliance[@id="{appl_id}"]/{switch.actuator}/{switch.func_type}/lock'
858
+ )
859
+ # Don't bother switching a relay when the corresponding lock-state is true
860
+ if self._appliances.find(locator).text == "true":
861
+ raise PlugwiseError("Plugwise: the locked Relay was not switched.")
862
+
863
+ await self._request(uri, method="put", data=data)
864
+
865
+ async def set_regulation_mode(self, mode: str) -> None:
866
+ """Set the heating regulation mode."""
867
+ if mode not in self._reg_allowed_modes:
868
+ raise PlugwiseError("Plugwise: invalid regulation mode.")
869
+
870
+ uri = f"{APPLIANCES};type=gateway/regulation_mode_control"
871
+ duration = ""
872
+ if "bleeding" in mode:
873
+ duration = "<duration>300</duration>"
874
+ data = f"<regulation_mode_control_functionality>{duration}<mode>{mode}</mode></regulation_mode_control_functionality>"
875
+
876
+ await self._request(uri, method="put", data=data)
877
+
878
+ async def set_dhw_mode(self, mode: str) -> None:
879
+ """Set the domestic hot water heating regulation mode."""
880
+ if mode not in self._dhw_allowed_modes:
881
+ raise PlugwiseError("Plugwise: invalid dhw mode.")
882
+
883
+ uri = f"{APPLIANCES};type=heater_central/domestic_hot_water_mode_control"
884
+ data = f"<domestic_hot_water_mode_control_functionality><mode>{mode}</mode></domestic_hot_water_mode_control_functionality>"
885
+
886
+ await self._request(uri, method="put", data=data)
887
+
888
+ async def delete_notification(self) -> None:
889
+ """Delete the active Plugwise Notification."""
890
+ await self._request(NOTIFICATIONS, method="delete")