plugwise 0.36.2__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,775 @@
1
+ """Use of this source code is governed by the MIT license found in the LICENSE file.
2
+
3
+ Plugwise Smile protocol helpers.
4
+ """
5
+ from __future__ import annotations
6
+
7
+ import datetime as dt
8
+ from typing import cast
9
+
10
+ from plugwise.constants import (
11
+ ACTIVE_ACTUATORS,
12
+ ACTUATOR_CLASSES,
13
+ ANNA,
14
+ APPLIANCES,
15
+ ATTR_NAME,
16
+ ATTR_UNIT_OF_MEASUREMENT,
17
+ BINARY_SENSORS,
18
+ DATA,
19
+ DEVICE_MEASUREMENTS,
20
+ ENERGY_WATT_HOUR,
21
+ FAKE_APPL,
22
+ FAKE_LOC,
23
+ HEATER_CENTRAL_MEASUREMENTS,
24
+ LIMITS,
25
+ NONE,
26
+ OBSOLETE_MEASUREMENTS,
27
+ P1_LEGACY_MEASUREMENTS,
28
+ SENSORS,
29
+ SPECIAL_PLUG_TYPES,
30
+ SPECIALS,
31
+ SWITCH_GROUP_TYPES,
32
+ SWITCHES,
33
+ TEMP_CELSIUS,
34
+ THERMOSTAT_CLASSES,
35
+ UOM,
36
+ ActuatorData,
37
+ ActuatorDataType,
38
+ ActuatorType,
39
+ ApplianceType,
40
+ BinarySensorType,
41
+ DeviceData,
42
+ GatewayData,
43
+ ModelData,
44
+ SensorType,
45
+ SpecialType,
46
+ SwitchType,
47
+ ThermoLoc,
48
+ )
49
+ from plugwise.util import format_measure, power_data_local_format, version_to_model
50
+
51
+ # This way of importing aiohttp is because of patch/mocking in testing (aiohttp timeouts)
52
+ from defusedxml import ElementTree as etree
53
+ from munch import Munch
54
+ import semver
55
+
56
+
57
+ def etree_to_dict(element: etree) -> dict[str, str]:
58
+ """Helper-function translating xml Element to dict."""
59
+ node: dict[str, str] = {}
60
+ if element is not None:
61
+ node.update(element.items())
62
+
63
+ return node
64
+
65
+
66
+ class SmileLegacyHelper:
67
+ """The SmileLegacyHelper class."""
68
+
69
+ def __init__(self) -> None:
70
+ """Set the constructor for this class."""
71
+ self._appliances: etree
72
+ self._count: int
73
+ self._domain_objects: etree
74
+ self._heater_id: str
75
+ self._home_location: str
76
+ self._is_thermostat: bool
77
+ self._last_modified: dict[str, str] = {}
78
+ self._locations: etree
79
+ self._modules: etree
80
+ self._notifications: dict[str, dict[str, str]] = {}
81
+ self._on_off_device: bool
82
+ self._opentherm_device: bool
83
+ self._outdoor_temp: float
84
+ self._status: etree
85
+ self._stretch_v2: bool
86
+ self._system: etree
87
+
88
+ self.device_items: int = 0
89
+ self.gateway_id: str
90
+ self.gw_data: GatewayData = {}
91
+ self.gw_devices: dict[str, DeviceData] = {}
92
+ self.loc_data: dict[str, ThermoLoc]
93
+ self.smile_fw_version: str | None = None
94
+ self.smile_hw_version: str | None = None
95
+ self.smile_legacy = False
96
+ self.smile_mac_address: str | None = None
97
+ self.smile_model: str
98
+ self.smile_name: str
99
+ self.smile_type: str
100
+ self.smile_version: tuple[str, semver.version.Version]
101
+ self.smile_zigbee_mac_address: str | None = None
102
+
103
+ def smile(self, name: str) -> bool:
104
+ """Helper-function checking the smile-name."""
105
+ return self.smile_name == name
106
+
107
+ def _all_locations(self) -> None:
108
+ """Collect all locations."""
109
+ loc = Munch()
110
+
111
+ # Legacy Anna without outdoor_temp and Stretches have no locations, create fake location-data
112
+ if not (locations := self._locations.findall("./location")):
113
+ self._home_location = FAKE_LOC
114
+ self.loc_data[FAKE_LOC] = {"name": "Home"}
115
+ return
116
+
117
+ for location in locations:
118
+ loc.name = location.find("name").text
119
+ loc.loc_id = location.attrib["id"]
120
+ # Filter the valid single location for P1 legacy: services not empty
121
+ locator = "./services"
122
+ if (
123
+ self.smile_type == "power"
124
+ and len(location.find(locator)) == 0
125
+ ):
126
+ continue
127
+
128
+ if loc.name == "Home":
129
+ self._home_location = loc.loc_id
130
+ # Replace location-name for P1 legacy, can contain privacy-related info
131
+ if self.smile_type == "power":
132
+ loc.name = "Home"
133
+ self._home_location = loc.loc_id
134
+
135
+ self.loc_data[loc.loc_id] = {"name": loc.name}
136
+
137
+ def _get_module_data(
138
+ self, appliance: etree, locator: str, mod_type: str
139
+ ) -> ModelData:
140
+ """Helper-function for _energy_device_info_finder() and _appliance_info_finder().
141
+
142
+ Collect requested info from MODULES.
143
+ """
144
+ model_data: ModelData = {
145
+ "contents": False,
146
+ "firmware_version": None,
147
+ "hardware_version": None,
148
+ "reachable": None,
149
+ "vendor_name": None,
150
+ "vendor_model": None,
151
+ "zigbee_mac_address": None,
152
+ }
153
+ if (appl_search := appliance.find(locator)) is not None:
154
+ link_id = appl_search.attrib["id"]
155
+ loc = f".//{mod_type}[@id='{link_id}']...."
156
+ # Not possible to walrus for some reason...
157
+ module = self._modules.find(loc)
158
+ if module is not None: # pylint: disable=consider-using-assignment-expr
159
+ model_data["contents"] = True
160
+ if (vendor_name := module.find("vendor_name").text) is not None:
161
+ model_data["vendor_name"] = vendor_name
162
+ if "Plugwise" in vendor_name:
163
+ model_data["vendor_name"] = vendor_name.split(" ", 1)[0]
164
+ model_data["vendor_model"] = module.find("vendor_model").text
165
+ model_data["hardware_version"] = module.find("hardware_version").text
166
+ model_data["firmware_version"] = module.find("firmware_version").text
167
+ # Stretches
168
+ if (router := module.find("./protocols/network_router")) is not None:
169
+ model_data["zigbee_mac_address"] = router.find("mac_address").text
170
+ # Also look for the Circle+/Stealth M+
171
+ if (coord := module.find("./protocols/network_coordinator")) is not None:
172
+ model_data["zigbee_mac_address"] = coord.find("mac_address").text
173
+
174
+ return model_data
175
+
176
+ def _energy_device_info_finder(self, appliance: etree, appl: Munch) -> Munch:
177
+ """Helper-function for _appliance_info_finder().
178
+
179
+ Collect energy device info (Circle, Plug, Stealth): firmware, model and vendor name.
180
+ """
181
+ if self.smile_type in ("power", "stretch"):
182
+ locator = "./services/electricity_point_meter"
183
+ mod_type = "electricity_point_meter"
184
+
185
+ module_data = self._get_module_data(appliance, locator, mod_type)
186
+ # Filter appliance without zigbee_mac, it's an orphaned device
187
+ appl.zigbee_mac = module_data["zigbee_mac_address"]
188
+ if appl.zigbee_mac is None and self.smile_type != "power":
189
+ return None
190
+
191
+ appl.hardware = module_data["hardware_version"]
192
+ appl.model = module_data["vendor_model"]
193
+ appl.vendor_name = module_data["vendor_name"]
194
+ if appl.hardware is not None:
195
+ hw_version = appl.hardware.replace("-", "")
196
+ appl.model = version_to_model(hw_version)
197
+ appl.firmware = module_data["firmware_version"]
198
+
199
+ return appl
200
+
201
+ return appl # pragma: no cover
202
+
203
+ def _appliance_info_finder(self, appliance: etree, appl: Munch) -> Munch:
204
+ """Collect device info (Smile/Stretch, Thermostats, OpenTherm/On-Off): firmware, model and vendor name."""
205
+ # Collect thermostat device info
206
+ if appl.pwclass in THERMOSTAT_CLASSES:
207
+ locator = "./logs/point_log[type='thermostat']/thermostat"
208
+ mod_type = "thermostat"
209
+ module_data = self._get_module_data(appliance, locator, mod_type)
210
+ appl.vendor_name = module_data["vendor_name"]
211
+ appl.model = module_data["vendor_model"]
212
+ appl.hardware = module_data["hardware_version"]
213
+ appl.firmware = module_data["firmware_version"]
214
+ appl.zigbee_mac = module_data["zigbee_mac_address"]
215
+ return appl
216
+
217
+ # Collect heater_central device info
218
+ if appl.pwclass == "heater_central":
219
+ # Remove heater_central when no active device present
220
+ if not self._opentherm_device and not self._on_off_device:
221
+ return None
222
+
223
+ # Find the valid heater_central
224
+ self._heater_id = self._check_heater_central()
225
+
226
+ # Info for On-Off device
227
+ if self._on_off_device:
228
+ appl.name = "OnOff" # pragma: no cover
229
+ appl.vendor_name = None # pragma: no cover
230
+ appl.model = "Unknown" # pragma: no cover
231
+ return appl # pragma: no cover
232
+
233
+ # Info for OpenTherm device
234
+ appl.name = "OpenTherm"
235
+ locator1 = "./logs/point_log[type='flame_state']/boiler_state"
236
+ locator2 = "./services/boiler_state"
237
+ mod_type = "boiler_state"
238
+ module_data = self._get_module_data(appliance, locator1, mod_type)
239
+ if not module_data["contents"]:
240
+ module_data = self._get_module_data(appliance, locator2, mod_type)
241
+ appl.vendor_name = module_data["vendor_name"]
242
+ appl.hardware = module_data["hardware_version"]
243
+ appl.model = module_data["vendor_model"]
244
+ if appl.model is None:
245
+ appl.model = "Generic heater"
246
+
247
+ return appl
248
+
249
+ # Collect info from Stretches
250
+ appl = self._energy_device_info_finder(appliance, appl)
251
+
252
+ return appl
253
+
254
+ def _check_heater_central(self) -> str:
255
+ """Find the valid heater_central, helper-function for _appliance_info_finder().
256
+
257
+ Solution for Core Issue #104433,
258
+ for a system that has two heater_central appliances.
259
+ """
260
+ locator = "./appliance[type='heater_central']"
261
+ hc_count = 0
262
+ hc_list: list[dict[str, bool]] = []
263
+ for heater_central in self._appliances.findall(locator):
264
+ hc_count += 1
265
+ hc_id: str = heater_central.attrib["id"]
266
+ has_actuators: bool = (
267
+ heater_central.find("actuator_functionalities/") is not None
268
+ )
269
+ hc_list.append({hc_id: has_actuators})
270
+
271
+ heater_central_id = list(hc_list[0].keys())[0]
272
+ if hc_count > 1:
273
+ for item in hc_list: # pragma: no cover
274
+ for key, value in item.items(): # pragma: no cover
275
+ if value: # pragma: no cover
276
+ heater_central_id = key # pragma: no cover
277
+ # Stop when a valid id is found
278
+ break # pragma: no cover
279
+
280
+ return heater_central_id
281
+
282
+ def _p1_smartmeter_info_finder(self, appl: Munch) -> None:
283
+ """Collect P1 DSMR Smartmeter info."""
284
+ loc_id = next(iter(self.loc_data.keys()))
285
+ appl.dev_id = loc_id
286
+ appl.location = loc_id
287
+ appl.mac = None
288
+ appl.model = self.smile_model
289
+ appl.name = "P1"
290
+ appl.pwclass = "smartmeter"
291
+ appl.zigbee_mac = None
292
+ location = self._locations.find(f'./location[@id="{loc_id}"]')
293
+ appl = self._energy_device_info_finder(location, appl)
294
+
295
+ self.gw_devices[appl.dev_id] = {"dev_class": appl.pwclass}
296
+ self._count += 1
297
+
298
+ for key, value in {
299
+ "firmware": appl.firmware,
300
+ "hardware": appl.hardware,
301
+ "location": appl.location,
302
+ "mac_address": appl.mac,
303
+ "model": appl.model,
304
+ "name": appl.name,
305
+ "zigbee_mac_address": appl.zigbee_mac,
306
+ "vendor": appl.vendor_name,
307
+ }.items():
308
+ if value is not None or key == "location":
309
+ p1_key = cast(ApplianceType, key)
310
+ self.gw_devices[appl.dev_id][p1_key] = value
311
+ self._count += 1
312
+
313
+ def _create_legacy_gateway(self) -> None:
314
+ """Create the (missing) gateway devices for legacy Anna, P1 and Stretch.
315
+
316
+ Use the home_location or FAKE_APPL as device id.
317
+ """
318
+ self.gateway_id = self._home_location
319
+ if self.smile_type == "power":
320
+ self.gateway_id = FAKE_APPL
321
+
322
+ self.gw_devices[self.gateway_id] = {"dev_class": "gateway"}
323
+ self._count += 1
324
+ for key, value in {
325
+ "firmware": self.smile_fw_version,
326
+ "location": self._home_location,
327
+ "mac_address": self.smile_mac_address,
328
+ "model": self.smile_model,
329
+ "name": self.smile_name,
330
+ "zigbee_mac_address": self.smile_zigbee_mac_address,
331
+ "vendor": "Plugwise",
332
+ }.items():
333
+ if value is not None:
334
+ gw_key = cast(ApplianceType, key)
335
+ self.gw_devices[self.gateway_id][gw_key] = value
336
+ self._count += 1
337
+
338
+ def _all_appliances(self) -> None:
339
+ """Collect all appliances with relevant info."""
340
+ self._count = 0
341
+ self._all_locations()
342
+
343
+ self._create_legacy_gateway()
344
+ # For legacy P1 collect the connected SmartMeter info
345
+ if self.smile_type == "power":
346
+ appl = Munch()
347
+ self._p1_smartmeter_info_finder(appl)
348
+ # Legacy P1 has no more devices
349
+ return
350
+
351
+ for appliance in self._appliances.findall("./appliance"):
352
+ appl = Munch()
353
+ appl.pwclass = appliance.find("type").text
354
+ # Skip thermostats that have this key, should be an orphaned device (Core #81712)
355
+ if (
356
+ appl.pwclass == "thermostat"
357
+ and appliance.find("actuator_functionalities/") is None
358
+ ):
359
+ continue # pragma: no cover
360
+
361
+ appl.location = self._home_location
362
+ appl.dev_id = appliance.attrib["id"]
363
+ appl.name = appliance.find("name").text
364
+ appl.model = appl.pwclass.replace("_", " ").title()
365
+ appl.firmware = None
366
+ appl.hardware = None
367
+ appl.mac = None
368
+ appl.zigbee_mac = None
369
+ appl.vendor_name = None
370
+
371
+ # Determine class for this appliance
372
+ # Skip on heater_central when no active device present or on orphaned stretch devices
373
+ if not (appl := self._appliance_info_finder(appliance, appl)):
374
+ continue
375
+
376
+ # Skip orphaned heater_central (Core Issue #104433)
377
+ if appl.pwclass == "heater_central" and appl.dev_id != self._heater_id:
378
+ continue # pragma: no cover
379
+
380
+ self.gw_devices[appl.dev_id] = {"dev_class": appl.pwclass}
381
+ self._count += 1
382
+ for key, value in {
383
+ "firmware": appl.firmware,
384
+ "hardware": appl.hardware,
385
+ "location": appl.location,
386
+ "mac_address": appl.mac,
387
+ "model": appl.model,
388
+ "name": appl.name,
389
+ "zigbee_mac_address": appl.zigbee_mac,
390
+ "vendor": appl.vendor_name,
391
+ }.items():
392
+ if value is not None or key == "location":
393
+ appl_key = cast(ApplianceType, key)
394
+ self.gw_devices[appl.dev_id][appl_key] = value
395
+ self._count += 1
396
+
397
+ # Place the gateway and optional heater_central devices as 1st and 2nd
398
+ for dev_class in ("heater_central", "gateway"):
399
+ for dev_id, device in dict(self.gw_devices).items():
400
+ if device["dev_class"] == dev_class:
401
+ tmp_device = device
402
+ self.gw_devices.pop(dev_id)
403
+ cleared_dict = self.gw_devices
404
+ add_to_front = {dev_id: tmp_device}
405
+ self.gw_devices = {**add_to_front, **cleared_dict}
406
+
407
+ def _presets(self) -> dict[str, list[float]]:
408
+ """Helper-function for presets() - collect Presets for a legacy Anna."""
409
+ presets: dict[str, list[float]] = {}
410
+ for directive in self._domain_objects.findall("rule/directives/when/then"):
411
+ if directive is not None and directive.get("icon") is not None:
412
+ # Ensure list of heating_setpoint, cooling_setpoint
413
+ presets[directive.attrib["icon"]] = [
414
+ float(directive.attrib["temperature"]),
415
+ 0,
416
+ ]
417
+
418
+ return presets
419
+
420
+ def _appliance_measurements(
421
+ self,
422
+ appliance: etree,
423
+ data: DeviceData,
424
+ measurements: dict[str, DATA | UOM],
425
+ ) -> None:
426
+ """Helper-function for _get_measurement_data() - collect appliance measurement data."""
427
+ for measurement, attrs in measurements.items():
428
+ p_locator = f'.//logs/point_log[type="{measurement}"]/period/measurement'
429
+ if (appl_p_loc := appliance.find(p_locator)) is not None:
430
+ if measurement == "domestic_hot_water_state":
431
+ continue
432
+
433
+ # Skip known obsolete measurements
434
+ updated_date_locator = (
435
+ f'.//logs/point_log[type="{measurement}"]/updated_date'
436
+ )
437
+ if (
438
+ measurement in OBSOLETE_MEASUREMENTS
439
+ and (updated_date_key := appliance.find(updated_date_locator))
440
+ is not None
441
+ ):
442
+ updated_date = updated_date_key.text.split("T")[0]
443
+ date_1 = dt.datetime.strptime(updated_date, "%Y-%m-%d")
444
+ date_2 = dt.datetime.now()
445
+ if int((date_2 - date_1).days) > 7:
446
+ continue # pragma: no cover
447
+
448
+ if new_name := getattr(attrs, ATTR_NAME, None):
449
+ measurement = new_name
450
+
451
+ match measurement:
452
+ case _ as measurement if measurement in BINARY_SENSORS:
453
+ bs_key = cast(BinarySensorType, measurement)
454
+ bs_value = appl_p_loc.text in ["on", "true"]
455
+ data["binary_sensors"][bs_key] = bs_value
456
+ case _ as measurement if measurement in SENSORS:
457
+ s_key = cast(SensorType, measurement)
458
+ s_value = format_measure(
459
+ appl_p_loc.text, getattr(attrs, ATTR_UNIT_OF_MEASUREMENT)
460
+ )
461
+ data["sensors"][s_key] = s_value
462
+ case _ as measurement if measurement in SWITCHES:
463
+ sw_key = cast(SwitchType, measurement)
464
+ sw_value = appl_p_loc.text in ["on", "true"]
465
+ data["switches"][sw_key] = sw_value
466
+ case _ as measurement if measurement in SPECIALS:
467
+ sp_key = cast(SpecialType, measurement)
468
+ sp_value = appl_p_loc.text in ["on", "true"]
469
+ data[sp_key] = sp_value
470
+
471
+ i_locator = f'.//logs/interval_log[type="{measurement}"]/period/measurement'
472
+ if (appl_i_loc := appliance.find(i_locator)) is not None:
473
+ name = cast(SensorType, f"{measurement}_interval")
474
+ data["sensors"][name] = format_measure(
475
+ appl_i_loc.text, ENERGY_WATT_HOUR
476
+ )
477
+
478
+ self._count += len(data["binary_sensors"])
479
+ self._count += len(data["sensors"])
480
+ self._count += len(data["switches"])
481
+ # Don't count the above top-level dicts, only the remaining single items
482
+ self._count += len(data) - 3
483
+
484
+ def _get_actuator_functionalities(
485
+ self, xml: etree, device: DeviceData, data: DeviceData
486
+ ) -> None:
487
+ """Helper-function for _get_measurement_data()."""
488
+ for item in ACTIVE_ACTUATORS:
489
+ # Skip max_dhw_temperature, not initially valid,
490
+ # skip thermostat for thermo_sensors
491
+ if item == "max_dhw_temperature" or (
492
+ item == "thermostat" and device["dev_class"] == "thermo_sensor"
493
+ ):
494
+ continue
495
+
496
+ temp_dict: ActuatorData = {}
497
+ functionality = "thermostat_functionality"
498
+
499
+ # When there is no updated_date-text, skip the actuator
500
+ updated_date_location = f'.//actuator_functionalities/{functionality}[type="{item}"]/updated_date'
501
+ if (
502
+ updated_date_key := xml.find(updated_date_location)
503
+ ) is not None and updated_date_key.text is None:
504
+ continue # pragma: no cover
505
+
506
+ for key in LIMITS:
507
+ locator = (
508
+ f'.//actuator_functionalities/{functionality}[type="{item}"]/{key}'
509
+ )
510
+ if (pw_function := xml.find(locator)) is not None:
511
+ act_key = cast(ActuatorDataType, key)
512
+ temp_dict[act_key] = format_measure(pw_function.text, TEMP_CELSIUS)
513
+ self._count += 1
514
+
515
+ if temp_dict:
516
+ act_item = cast(ActuatorType, item)
517
+ data[act_item] = temp_dict
518
+
519
+ def _get_measurement_data(self, dev_id: str) -> DeviceData:
520
+ """Helper-function for smile.py: _get_device_data().
521
+
522
+ Collect the appliance-data based on device id.
523
+ """
524
+ data: DeviceData = {"binary_sensors": {}, "sensors": {}, "switches": {}}
525
+ # Get P1 smartmeter data from LOCATIONS or MODULES
526
+ device = self.gw_devices[dev_id]
527
+ # !! DON'T CHANGE below two if-lines, will break stuff !!
528
+ if self.smile_type == "power":
529
+ if device["dev_class"] == "smartmeter":
530
+ data.update(self._power_data_from_modules())
531
+
532
+ return data
533
+
534
+ measurements = DEVICE_MEASUREMENTS
535
+ if self._is_thermostat and dev_id == self._heater_id:
536
+ measurements = HEATER_CENTRAL_MEASUREMENTS
537
+
538
+ if (
539
+ appliance := self._appliances.find(f'./appliance[@id="{dev_id}"]')
540
+ ) is not None:
541
+ self._appliance_measurements(appliance, data, measurements)
542
+ self._get_lock_state(appliance, data)
543
+
544
+ if appliance.find("type").text in ACTUATOR_CLASSES:
545
+ self._get_actuator_functionalities(appliance, device, data)
546
+
547
+ # Adam & Anna: the Smile outdoor_temperature is present in DOMAIN_OBJECTS and LOCATIONS - under Home
548
+ # The outdoor_temperature present in APPLIANCES is a local sensor connected to the active device
549
+ if self._is_thermostat and dev_id == self.gateway_id:
550
+ outdoor_temperature = self._object_value(
551
+ self._home_location, "outdoor_temperature"
552
+ )
553
+ if outdoor_temperature is not None:
554
+ data.update({"sensors": {"outdoor_temperature": outdoor_temperature}})
555
+ self._count += 1
556
+
557
+ if "c_heating_state" in data:
558
+ data.pop("c_heating_state")
559
+ self._count -= 1
560
+
561
+ return data
562
+
563
+ def _thermostat_uri(self) -> str:
564
+ """Determine the location-set_temperature uri - from APPLIANCES."""
565
+ locator = "./appliance[type='thermostat']"
566
+ appliance_id = self._appliances.find(locator).attrib["id"]
567
+
568
+ return f"{APPLIANCES};id={appliance_id}/thermostat"
569
+
570
+ def _get_group_switches(self) -> dict[str, DeviceData]:
571
+ """Helper-function for smile.py: get_all_devices().
572
+
573
+ Collect switching- or pump-group info.
574
+ """
575
+ switch_groups: dict[str, DeviceData] = {}
576
+ # P1 and Anna don't have switchgroups
577
+ if self.smile_type == "power" or self.smile(ANNA):
578
+ return switch_groups
579
+
580
+ for group in self._domain_objects.findall("./group"):
581
+ members: list[str] = []
582
+ group_id = group.attrib["id"]
583
+ group_name = group.find("name").text
584
+ group_type = group.find("type").text
585
+ group_appliances = group.findall("appliances/appliance")
586
+ for item in group_appliances:
587
+ # Check if members are not orphaned - stretch
588
+ if item.attrib["id"] in self.gw_devices:
589
+ members.append(item.attrib["id"])
590
+
591
+ if group_type in SWITCH_GROUP_TYPES and members:
592
+ switch_groups.update(
593
+ {
594
+ group_id: {
595
+ "dev_class": group_type,
596
+ "model": "Switchgroup",
597
+ "name": group_name,
598
+ "members": members,
599
+ },
600
+ },
601
+ )
602
+ self._count += 4
603
+
604
+ return switch_groups
605
+
606
+ def power_data_energy_diff(
607
+ self,
608
+ measurement: str,
609
+ net_string: SensorType,
610
+ f_val: float | int,
611
+ direct_data: DeviceData,
612
+ ) -> DeviceData:
613
+ """Calculate differential energy."""
614
+ if (
615
+ "electricity" in measurement
616
+ and "phase" not in measurement
617
+ and "interval" not in net_string
618
+ ):
619
+ diff = 1
620
+ if "produced" in measurement:
621
+ diff = -1
622
+ if net_string not in direct_data["sensors"]:
623
+ tmp_val: float | int = 0
624
+ else:
625
+ tmp_val = direct_data["sensors"][net_string]
626
+
627
+ if isinstance(f_val, int):
628
+ tmp_val += f_val * diff
629
+ else:
630
+ tmp_val += float(f_val * diff)
631
+ tmp_val = float(f"{round(tmp_val, 3):.3f}")
632
+
633
+ direct_data["sensors"][net_string] = tmp_val
634
+
635
+ return direct_data
636
+
637
+ def _power_data_peak_value(self, loc: Munch) -> Munch:
638
+ """Helper-function for _power_data_from_location() and _power_data_from_modules()."""
639
+ loc.found = True
640
+ # If locator not found for P1 legacy electricity_point_meter or gas_*_meter data
641
+ if loc.logs.find(loc.locator) is None:
642
+ if "meter" in loc.log_type and (
643
+ "point" in loc.log_type or "gas" in loc.measurement
644
+ ):
645
+ # Avoid double processing by skipping one peak-list option
646
+ if loc.peak_select == "nl_offpeak":
647
+ loc.found = False
648
+ return loc
649
+
650
+ loc.locator = (
651
+ f"./{loc.meas_list[0]}_{loc.log_type}/"
652
+ f'measurement[@directionality="{loc.meas_list[1]}"]'
653
+ )
654
+ if loc.logs.find(loc.locator) is None:
655
+ loc.found = False
656
+ return loc
657
+ else:
658
+ loc.found = False
659
+ return loc
660
+
661
+ if (peak := loc.peak_select.split("_")[1]) == "offpeak":
662
+ peak = "off_peak"
663
+ log_found = loc.log_type.split("_")[0]
664
+ loc.key_string = f"{loc.measurement}_{peak}_{log_found}"
665
+ if "gas" in loc.measurement or loc.log_type == "point_meter":
666
+ loc.key_string = f"{loc.measurement}_{log_found}"
667
+ loc.net_string = f"net_electricity_{log_found}"
668
+ val = loc.logs.find(loc.locator).text
669
+ loc.f_val = power_data_local_format(loc.attrs, loc.key_string, val)
670
+
671
+ return loc
672
+
673
+ def _power_data_from_modules(self) -> DeviceData:
674
+ """Helper-function for smile.py: _get_device_data().
675
+
676
+ Collect the power-data from MODULES (P1 legacy only).
677
+ """
678
+ direct_data: DeviceData = {"sensors": {}}
679
+ loc = Munch()
680
+ mod_list: list[str] = ["interval_meter", "cumulative_meter", "point_meter"]
681
+ peak_list: list[str] = ["nl_peak", "nl_offpeak"]
682
+ t_string = "tariff_indicator"
683
+
684
+ search = self._modules
685
+ mod_logs = search.findall("./module/services")
686
+ for loc.measurement, loc.attrs in P1_LEGACY_MEASUREMENTS.items():
687
+ loc.meas_list = loc.measurement.split("_")
688
+ for loc.logs in mod_logs:
689
+ for loc.log_type in mod_list:
690
+ for loc.peak_select in peak_list:
691
+ loc.locator = (
692
+ f"./{loc.meas_list[0]}_{loc.log_type}/measurement"
693
+ f'[@directionality="{loc.meas_list[1]}"][@{t_string}="{loc.peak_select}"]'
694
+ )
695
+ loc = self._power_data_peak_value(loc)
696
+ if not loc.found:
697
+ continue
698
+
699
+ direct_data = self.power_data_energy_diff(
700
+ loc.measurement, loc.net_string, loc.f_val, direct_data
701
+ )
702
+ key = cast(SensorType, loc.key_string)
703
+ direct_data["sensors"][key] = loc.f_val
704
+
705
+ self._count += len(direct_data["sensors"])
706
+ return direct_data
707
+
708
+ def _preset(self) -> str | None:
709
+ """Helper-function for smile.py: device_data_climate().
710
+
711
+ Collect the active preset based on the active rule.
712
+ """
713
+ locator = "./rule[active='true']/directives/when/then"
714
+ if (
715
+ not (active_rule := etree_to_dict(self._domain_objects.find(locator)))
716
+ or "icon" not in active_rule
717
+ ):
718
+ return None
719
+
720
+ return active_rule["icon"]
721
+
722
+ def _schedules(self) -> tuple[list[str], str]:
723
+ """Collect available schedules/schedules for the legacy thermostat."""
724
+ available: list[str] = [NONE]
725
+ selected = NONE
726
+ name: str | None = None
727
+
728
+ search = self._domain_objects
729
+ for schedule in search.findall("./rule"):
730
+ if rule_name := schedule.find("name").text:
731
+ if "preset" not in rule_name:
732
+ name = rule_name
733
+
734
+ log_type = "schedule_state"
735
+ locator = f"./appliance[type='thermostat']/logs/point_log[type='{log_type}']/period/measurement"
736
+ active = False
737
+ if (result := search.find(locator)) is not None:
738
+ active = result.text == "on"
739
+
740
+ if name is not None:
741
+ available = [name]
742
+ if active:
743
+ selected = name
744
+
745
+ return available, selected
746
+
747
+ def _object_value(self, obj_id: str, measurement: str) -> float | int | None:
748
+ """Helper-function for smile.py: _get_device_data() and _device_data_anna().
749
+
750
+ Obtain the value/state for the given object from a location in DOMAIN_OBJECTS
751
+ """
752
+ val: float | int | None = None
753
+ search = self._domain_objects
754
+ locator = f'./location[@id="{obj_id}"]/logs/point_log[type="{measurement}"]/period/measurement'
755
+ if (found := search.find(locator)) is not None:
756
+ val = format_measure(found.text, NONE)
757
+ return val
758
+
759
+ return val
760
+
761
+ def _get_lock_state(self, xml: etree, data: DeviceData) -> None:
762
+ """Helper-function for _get_measurement_data().
763
+
764
+ Adam & Stretches: obtain the relay-switch lock state.
765
+ """
766
+ actuator = "actuator_functionalities"
767
+ func_type = "relay_functionality"
768
+ if self._stretch_v2:
769
+ actuator = "actuators"
770
+ func_type = "relay"
771
+ if xml.find("type").text not in SPECIAL_PLUG_TYPES:
772
+ locator = f"./{actuator}/{func_type}/lock"
773
+ if (found := xml.find(locator)) is not None:
774
+ data["switches"]["lock"] = found.text == "true"
775
+ self._count += 1