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/helper.py ADDED
@@ -0,0 +1,1552 @@
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 asyncio
8
+ import datetime as dt
9
+ from typing import cast
10
+
11
+ # This way of importing aiohttp is because of patch/mocking in testing (aiohttp timeouts)
12
+ from aiohttp import BasicAuth, ClientError, ClientResponse, ClientSession, ClientTimeout
13
+
14
+ # Time related
15
+ from dateutil import tz
16
+ from dateutil.parser import parse
17
+ from defusedxml import ElementTree as etree
18
+ from munch import Munch
19
+ import semver
20
+
21
+ from .constants import (
22
+ ACTIVE_ACTUATORS,
23
+ ACTUATOR_CLASSES,
24
+ ADAM,
25
+ ANNA,
26
+ APPLIANCES,
27
+ ATTR_NAME,
28
+ ATTR_UNIT_OF_MEASUREMENT,
29
+ BINARY_SENSORS,
30
+ DATA,
31
+ DEVICE_MEASUREMENTS,
32
+ DHW_SETPOINT,
33
+ ENERGY_KILO_WATT_HOUR,
34
+ ENERGY_WATT_HOUR,
35
+ FAKE_APPL,
36
+ FAKE_LOC,
37
+ HEATER_CENTRAL_MEASUREMENTS,
38
+ LIMITS,
39
+ LOCATIONS,
40
+ LOGGER,
41
+ NONE,
42
+ OBSOLETE_MEASUREMENTS,
43
+ OFF,
44
+ P1_LEGACY_MEASUREMENTS,
45
+ P1_MEASUREMENTS,
46
+ POWER_WATT,
47
+ SENSORS,
48
+ SPECIAL_PLUG_TYPES,
49
+ SWITCH_GROUP_TYPES,
50
+ SWITCHES,
51
+ TEMP_CELSIUS,
52
+ THERMOSTAT_CLASSES,
53
+ TOGGLES,
54
+ UOM,
55
+ ActuatorData,
56
+ ActuatorDataType,
57
+ ActuatorType,
58
+ ApplianceType,
59
+ BinarySensorType,
60
+ DeviceData,
61
+ GatewayData,
62
+ ModelData,
63
+ SensorType,
64
+ SwitchType,
65
+ ThermoLoc,
66
+ ToggleNameType,
67
+ )
68
+ from .exceptions import (
69
+ ConnectionFailedError,
70
+ InvalidAuthentication,
71
+ InvalidXMLError,
72
+ ResponseError,
73
+ )
74
+ from .util import escape_illegal_xml_characters, format_measure, version_to_model
75
+
76
+
77
+ def check_model(name: str | None, vendor_name: str | None) -> str | None:
78
+ """Model checking before using version_to_model."""
79
+ if vendor_name == "Plugwise" and ((model := version_to_model(name)) != "Unknown"):
80
+ return model
81
+
82
+ return name
83
+
84
+
85
+ def etree_to_dict(element: etree) -> dict[str, str]:
86
+ """Helper-function translating xml Element to dict."""
87
+ node: dict[str, str] = {}
88
+ if element is not None:
89
+ node.update(element.items())
90
+
91
+ return node
92
+
93
+
94
+ def power_data_local_format(
95
+ attrs: dict[str, str], key_string: str, val: str
96
+ ) -> float | int:
97
+ """Format power data."""
98
+ # Special formatting of P1_MEASUREMENT POWER_WATT values, do not move to util-format_measure() function!
99
+ if all(item in key_string for item in ("electricity", "cumulative")):
100
+ return format_measure(val, ENERGY_KILO_WATT_HOUR)
101
+ if (attrs_uom := getattr(attrs, ATTR_UNIT_OF_MEASUREMENT)) == POWER_WATT:
102
+ return int(round(float(val)))
103
+
104
+ return format_measure(val, attrs_uom)
105
+
106
+
107
+ class SmileComm:
108
+ """The SmileComm class."""
109
+
110
+ def __init__(
111
+ self,
112
+ host: str,
113
+ password: str,
114
+ username: str,
115
+ port: int,
116
+ timeout: float,
117
+ websession: ClientSession | None,
118
+ ) -> None:
119
+ """Set the constructor for this class."""
120
+ if not websession:
121
+ aio_timeout = ClientTimeout(total=timeout)
122
+
123
+ async def _create_session() -> ClientSession:
124
+ return ClientSession(timeout=aio_timeout) # pragma: no cover
125
+
126
+ loop = asyncio.get_event_loop()
127
+ if loop.is_running():
128
+ self._websession = ClientSession(timeout=aio_timeout)
129
+ else:
130
+ self._websession = loop.run_until_complete(
131
+ _create_session()
132
+ ) # pragma: no cover
133
+ else:
134
+ self._websession = websession
135
+
136
+ # Quickfix IPv6 formatting, not covering
137
+ if host.count(":") > 2: # pragma: no cover
138
+ host = f"[{host}]"
139
+
140
+ self._auth = BasicAuth(username, password=password)
141
+ self._endpoint = f"http://{host}:{str(port)}"
142
+ self._timeout = timeout
143
+
144
+ async def _request_validate(self, resp: ClientResponse, method: str) -> etree:
145
+ """Helper-function for _request(): validate the returned data."""
146
+ # Command accepted gives empty body with status 202
147
+ if resp.status == 202:
148
+ return
149
+ # Cornercase for stretch not responding with 202
150
+ if method == "put" and resp.status == 200:
151
+ return
152
+
153
+ if resp.status == 401:
154
+ msg = "Invalid Plugwise login, please retry with the correct credentials."
155
+ LOGGER.error("%s", msg)
156
+ raise InvalidAuthentication
157
+
158
+ if not (result := await resp.text()) or "<error>" in result:
159
+ LOGGER.warning("Smile response empty or error in %s", result)
160
+ raise ResponseError
161
+
162
+ try:
163
+ # Encode to ensure utf8 parsing
164
+ xml = etree.XML(escape_illegal_xml_characters(result).encode())
165
+ except etree.ParseError:
166
+ LOGGER.warning("Smile returns invalid XML for %s", self._endpoint)
167
+ raise InvalidXMLError
168
+
169
+ return xml
170
+
171
+ async def _request(
172
+ self,
173
+ command: str,
174
+ retry: int = 3,
175
+ method: str = "get",
176
+ data: str | None = None,
177
+ headers: dict[str, str] | None = None,
178
+ ) -> etree:
179
+ """Get/put/delete data from a give URL."""
180
+ resp: ClientResponse
181
+ url = f"{self._endpoint}{command}"
182
+
183
+ try:
184
+ if method == "delete":
185
+ resp = await self._websession.delete(url, auth=self._auth)
186
+ if method == "get":
187
+ # Work-around for Stretchv2, should not hurt the other smiles
188
+ headers = {"Accept-Encoding": "gzip"}
189
+ resp = await self._websession.get(url, headers=headers, auth=self._auth)
190
+ if method == "put":
191
+ headers = {"Content-type": "text/xml"}
192
+ resp = await self._websession.put(
193
+ url,
194
+ headers=headers,
195
+ data=data,
196
+ auth=self._auth,
197
+ )
198
+ except (
199
+ ClientError
200
+ ) as err: # ClientError is an ancestor class of ServerTimeoutError
201
+ if retry < 1:
202
+ LOGGER.warning(
203
+ "Failed sending %s %s to Plugwise Smile, error: %s",
204
+ method,
205
+ command,
206
+ err,
207
+ )
208
+ raise ConnectionFailedError
209
+ return await self._request(command, retry - 1)
210
+
211
+ return await self._request_validate(resp, method)
212
+
213
+ async def close_connection(self) -> None:
214
+ """Close the Plugwise connection."""
215
+ await self._websession.close()
216
+
217
+
218
+ class SmileHelper:
219
+ """The SmileHelper class."""
220
+
221
+ def __init__(self) -> None:
222
+ """Set the constructor for this class."""
223
+ self._appliances: etree
224
+ self._cooling_activation_outdoor_temp: float
225
+ self._cooling_deactivation_threshold: float
226
+ self._cooling_present = False
227
+ self._count: int
228
+ self._dhw_allowed_modes: list[str] = []
229
+ self._domain_objects: etree
230
+ self._elga = False
231
+ self._heater_id: str
232
+ self._home_location: str
233
+ self._is_thermostat = False
234
+ self._last_active: dict[str, str | None] = {}
235
+ self._last_modified: dict[str, str] = {}
236
+ self._locations: etree
237
+ self._loc_data: dict[str, ThermoLoc] = {}
238
+ self._modules: etree
239
+ self._notifications: dict[str, dict[str, str]] = {}
240
+ self._on_off_device = False
241
+ self._opentherm_device = False
242
+ self._outdoor_temp: float
243
+ self._reg_allowed_modes: list[str] = []
244
+ self._schedule_old_states: dict[str, dict[str, str]] = {}
245
+ self._smile_legacy = False
246
+ self._status: etree
247
+ self._stretch_v2 = False
248
+ self._stretch_v3 = False
249
+ self._system: etree
250
+ self._thermo_locs: dict[str, ThermoLoc] = {}
251
+ ###################################################################
252
+ # '_cooling_enabled' can refer to the state of the Elga heatpump
253
+ # connected to an Anna. For Elga, 'elga_status_code' in [8, 9]
254
+ # means cooling mode is available, next to heating mode.
255
+ # 'elga_status_code' = 8 means cooling is active, 9 means idle.
256
+ #
257
+ # '_cooling_enabled' cam refer to the state of the Loria or
258
+ # Thermastage heatpump connected to an Anna. For these,
259
+ # 'cooling_enabled' = on means set to cooling mode, instead of to
260
+ # heating mode.
261
+ # 'cooling_state' = on means cooling is active.
262
+ ###################################################################
263
+ self._cooling_active = False
264
+ self._cooling_enabled = False
265
+
266
+ self.device_items: int = 0
267
+ self.device_list: list[str]
268
+ self.gateway_id: str
269
+ self.gw_data: GatewayData = {}
270
+ self.gw_devices: dict[str, DeviceData] = {}
271
+ self.smile_fw_version: str | None = None
272
+ self.smile_hw_version: str | None = None
273
+ self.smile_mac_address: str | None = None
274
+ self.smile_model: str
275
+ self.smile_name: str
276
+ self.smile_type: str
277
+ self.smile_version: tuple[str, semver.version.Version]
278
+ self.smile_zigbee_mac_address: str | None = None
279
+ self.therms_with_offset_func: list[str] = []
280
+
281
+ def smile(self, name: str) -> bool:
282
+ """Helper-function checking the smile-name."""
283
+ return self.smile_name == name
284
+
285
+ def _all_locations(self) -> None:
286
+ """Collect all locations."""
287
+ loc = Munch()
288
+
289
+ locations = self._locations.findall("./location")
290
+ # Legacy Anna without outdoor_temp and Stretches have no locations, create fake location-data
291
+ if not locations and self._smile_legacy:
292
+ self._home_location = FAKE_LOC
293
+ self._loc_data[FAKE_LOC] = {"name": "Home"}
294
+ return
295
+
296
+ for location in locations:
297
+ loc.name = location.find("name").text
298
+ loc.loc_id = location.attrib["id"]
299
+ # Filter the valid single location for P1 legacy: services not empty
300
+ locator = "./services"
301
+ if (
302
+ self._smile_legacy
303
+ and self.smile_type == "power"
304
+ and len(location.find(locator)) == 0
305
+ ):
306
+ continue
307
+
308
+ if loc.name == "Home":
309
+ self._home_location = loc.loc_id
310
+ # Replace location-name for P1 legacy, can contain privacy-related info
311
+ if self._smile_legacy and self.smile_type == "power":
312
+ loc.name = "Home"
313
+ self._home_location = loc.loc_id
314
+
315
+ self._loc_data[loc.loc_id] = {"name": loc.name}
316
+
317
+ return
318
+
319
+ def _get_module_data(
320
+ self, appliance: etree, locator: str, mod_type: str
321
+ ) -> ModelData:
322
+ """Helper-function for _energy_device_info_finder() and _appliance_info_finder().
323
+
324
+ Collect requested info from MODULES.
325
+ """
326
+ model_data: ModelData = {
327
+ "contents": False,
328
+ "firmware_version": None,
329
+ "hardware_version": None,
330
+ "reachable": None,
331
+ "vendor_name": None,
332
+ "vendor_model": None,
333
+ "zigbee_mac_address": None,
334
+ }
335
+ if (appl_search := appliance.find(locator)) is not None:
336
+ link_id = appl_search.attrib["id"]
337
+ loc = f".//{mod_type}[@id='{link_id}']...."
338
+ # Not possible to walrus for some reason...
339
+ module = self._modules.find(loc)
340
+ if module is not None: # pylint: disable=consider-using-assignment-expr
341
+ model_data["contents"] = True
342
+ if (vendor_name := module.find("vendor_name").text) is not None:
343
+ model_data["vendor_name"] = vendor_name
344
+ if "Plugwise" in vendor_name:
345
+ model_data["vendor_name"] = vendor_name.split(" ", 1)[0]
346
+ model_data["vendor_model"] = module.find("vendor_model").text
347
+ model_data["hardware_version"] = module.find("hardware_version").text
348
+ model_data["firmware_version"] = module.find("firmware_version").text
349
+ # Adam
350
+ if zb_node := module.find("./protocols/zig_bee_node"):
351
+ model_data["zigbee_mac_address"] = zb_node.find("mac_address").text
352
+ model_data["reachable"] = zb_node.find("reachable").text == "true"
353
+ # Stretches
354
+ if router := module.find("./protocols/network_router"):
355
+ model_data["zigbee_mac_address"] = router.find("mac_address").text
356
+ # Also look for the Circle+/Stealth M+
357
+ if coord := module.find("./protocols/network_coordinator"):
358
+ model_data["zigbee_mac_address"] = coord.find("mac_address").text
359
+
360
+ return model_data
361
+
362
+ def _energy_device_info_finder(self, appliance: etree, appl: Munch) -> Munch:
363
+ """Helper-function for _appliance_info_finder().
364
+
365
+ Collect energy device info (Circle, Plug, Stealth): firmware, model and vendor name.
366
+ """
367
+ if self.smile_type in ("power", "stretch"):
368
+ locator = "./services/electricity_point_meter"
369
+ if not self._smile_legacy:
370
+ locator = "./logs/point_log/electricity_point_meter"
371
+ mod_type = "electricity_point_meter"
372
+
373
+ module_data = self._get_module_data(appliance, locator, mod_type)
374
+ # Filter appliance without zigbee_mac, it's an orphaned device
375
+ appl.zigbee_mac = module_data["zigbee_mac_address"]
376
+ if appl.zigbee_mac is None and self.smile_type != "power":
377
+ return None
378
+
379
+ appl.hardware = module_data["hardware_version"]
380
+ appl.model = module_data["vendor_model"]
381
+ appl.vendor_name = module_data["vendor_name"]
382
+ if appl.hardware is not None:
383
+ hw_version = appl.hardware.replace("-", "")
384
+ appl.model = version_to_model(hw_version)
385
+ appl.firmware = module_data["firmware_version"]
386
+
387
+ return appl
388
+
389
+ if self.smile(ADAM):
390
+ locator = "./logs/interval_log/electricity_interval_meter"
391
+ mod_type = "electricity_interval_meter"
392
+ module_data = self._get_module_data(appliance, locator, mod_type)
393
+ # Filter appliance without zigbee_mac, it's an orphaned device
394
+ appl.zigbee_mac = module_data["zigbee_mac_address"]
395
+ if appl.zigbee_mac is None:
396
+ return None
397
+
398
+ appl.vendor_name = module_data["vendor_name"]
399
+ appl.model = check_model(module_data["vendor_model"], appl.vendor_name)
400
+ appl.hardware = module_data["hardware_version"]
401
+ appl.firmware = module_data["firmware_version"]
402
+
403
+ return appl
404
+
405
+ return appl # pragma: no cover
406
+
407
+ def _appliance_info_finder(self, appliance: etree, appl: Munch) -> Munch:
408
+ """Collect device info (Smile/Stretch, Thermostats, OpenTherm/On-Off): firmware, model and vendor name."""
409
+ # Collect gateway device info
410
+ if appl.pwclass == "gateway":
411
+ self.gateway_id = appliance.attrib["id"]
412
+ appl.firmware = self.smile_fw_version
413
+ appl.hardware = self.smile_hw_version
414
+ appl.mac = self.smile_mac_address
415
+ appl.model = self.smile_model
416
+ appl.name = self.smile_name
417
+ appl.vendor_name = "Plugwise"
418
+
419
+ # Adam: look for the ZigBee MAC address of the Smile
420
+ if self.smile(ADAM) and (
421
+ found := self._modules.find(".//protocols/zig_bee_coordinator")
422
+ ):
423
+ appl.zigbee_mac = found.find("mac_address").text
424
+
425
+ # Adam: collect modes and check for cooling, indicating cooling-mode is present
426
+ reg_mode_list: list[str] = []
427
+ locator = "./actuator_functionalities/regulation_mode_control_functionality"
428
+ if (search := appliance.find(locator)) is not None:
429
+ if search.find("allowed_modes") is not None:
430
+ for mode in search.find("allowed_modes"):
431
+ reg_mode_list.append(mode.text)
432
+ if mode.text == "cooling":
433
+ self._cooling_present = True
434
+ self._reg_allowed_modes = reg_mode_list
435
+
436
+ return appl
437
+
438
+ # Collect thermostat device info
439
+ if appl.pwclass in THERMOSTAT_CLASSES:
440
+ locator = "./logs/point_log[type='thermostat']/thermostat"
441
+ mod_type = "thermostat"
442
+ module_data = self._get_module_data(appliance, locator, mod_type)
443
+ appl.vendor_name = module_data["vendor_name"]
444
+ appl.model = check_model(module_data["vendor_model"], appl.vendor_name)
445
+ appl.hardware = module_data["hardware_version"]
446
+ appl.firmware = module_data["firmware_version"]
447
+ appl.zigbee_mac = module_data["zigbee_mac_address"]
448
+
449
+ return appl
450
+
451
+ # Collect heater_central device info
452
+ if appl.pwclass == "heater_central":
453
+ # Remove heater_central when no active device present
454
+ if not self._opentherm_device and not self._on_off_device:
455
+ return None
456
+
457
+ # Find the valid heater_central
458
+ self._heater_id = self._check_heater_central()
459
+
460
+ # Info for On-Off device
461
+ if self._on_off_device:
462
+ appl.name = "OnOff"
463
+ appl.vendor_name = None
464
+ appl.model = "Unknown"
465
+ return appl
466
+
467
+ # Info for OpenTherm device
468
+ appl.name = "OpenTherm"
469
+ locator1 = "./logs/point_log[type='flame_state']/boiler_state"
470
+ locator2 = "./services/boiler_state"
471
+ mod_type = "boiler_state"
472
+ module_data = self._get_module_data(appliance, locator1, mod_type)
473
+ if not module_data["contents"]:
474
+ module_data = self._get_module_data(appliance, locator2, mod_type)
475
+ appl.vendor_name = module_data["vendor_name"]
476
+ appl.hardware = module_data["hardware_version"]
477
+ appl.model = check_model(module_data["vendor_model"], appl.vendor_name)
478
+ if appl.model is None:
479
+ appl.model = (
480
+ "Generic heater/cooler"
481
+ if self._cooling_present
482
+ else "Generic heater"
483
+ )
484
+
485
+ # Anna + Loria: collect dhw control operation modes
486
+ dhw_mode_list: list[str] = []
487
+ locator = "./actuator_functionalities/domestic_hot_water_mode_control_functionality"
488
+ if (search := appliance.find(locator)) is not None:
489
+ if search.find("allowed_modes") is not None:
490
+ for mode in search.find("allowed_modes"):
491
+ dhw_mode_list.append(mode.text)
492
+ self._dhw_allowed_modes = dhw_mode_list
493
+
494
+ return appl
495
+
496
+ # Collect info from Stretches
497
+ appl = self._energy_device_info_finder(appliance, appl)
498
+
499
+ return appl
500
+
501
+ def _check_heater_central(self) -> str:
502
+ """Find the valid heater_central, helper-function for _appliance_info_finder().
503
+
504
+ Solution for Core Issue #104433,
505
+ for a system that has two heater_central appliances.
506
+ """
507
+ locator = "./appliance[type='heater_central']"
508
+ hc_count = 0
509
+ hc_list: list[dict[str, bool]] = []
510
+ for heater_central in self._appliances.findall(locator):
511
+ hc_count += 1
512
+ hc_id: str = heater_central.attrib["id"]
513
+ has_actuators: bool = (
514
+ heater_central.find("actuator_functionalities/") is not None
515
+ )
516
+ hc_list.append({hc_id: has_actuators})
517
+
518
+ heater_central_id = list(hc_list[0].keys())[0]
519
+ if hc_count > 1:
520
+ for item in hc_list:
521
+ for key, value in item.items():
522
+ if value:
523
+ heater_central_id = key
524
+ # Stop when a valid id is found
525
+ break
526
+
527
+ return heater_central_id
528
+
529
+ def _p1_smartmeter_info_finder(self, appl: Munch) -> None:
530
+ """Collect P1 DSMR Smartmeter info."""
531
+ loc_id = next(iter(self._loc_data.keys()))
532
+ appl.dev_id = self.gateway_id
533
+ appl.location = loc_id
534
+ if self._smile_legacy:
535
+ appl.dev_id = loc_id
536
+ appl.mac = None
537
+ appl.model = self.smile_model
538
+ appl.name = "P1"
539
+ appl.pwclass = "smartmeter"
540
+ appl.zigbee_mac = None
541
+ location = self._locations.find(f'./location[@id="{loc_id}"]')
542
+ appl = self._energy_device_info_finder(location, appl)
543
+
544
+ self.gw_devices[appl.dev_id] = {"dev_class": appl.pwclass}
545
+ self._count += 1
546
+
547
+ for key, value in {
548
+ "firmware": appl.firmware,
549
+ "hardware": appl.hardware,
550
+ "location": appl.location,
551
+ "mac_address": appl.mac,
552
+ "model": appl.model,
553
+ "name": appl.name,
554
+ "zigbee_mac_address": appl.zigbee_mac,
555
+ "vendor": appl.vendor_name,
556
+ }.items():
557
+ if value is not None or key == "location":
558
+ p1_key = cast(ApplianceType, key)
559
+ self.gw_devices[appl.dev_id][p1_key] = value
560
+ self._count += 1
561
+
562
+ def _create_legacy_gateway(self) -> None:
563
+ """Create the (missing) gateway devices for legacy Anna, P1 and Stretch.
564
+
565
+ Use the home_location or FAKE_APPL as device id.
566
+ """
567
+ self.gateway_id = self._home_location
568
+ if self.smile_type == "power":
569
+ self.gateway_id = FAKE_APPL
570
+
571
+ self.gw_devices[self.gateway_id] = {"dev_class": "gateway"}
572
+ self._count += 1
573
+ for key, value in {
574
+ "firmware": self.smile_fw_version,
575
+ "location": self._home_location,
576
+ "mac_address": self.smile_mac_address,
577
+ "model": self.smile_model,
578
+ "name": self.smile_name,
579
+ "zigbee_mac_address": self.smile_zigbee_mac_address,
580
+ "vendor": "Plugwise",
581
+ }.items():
582
+ if value is not None:
583
+ gw_key = cast(ApplianceType, key)
584
+ self.gw_devices[self.gateway_id][gw_key] = value
585
+ self._count += 1
586
+
587
+ def _all_appliances(self) -> None:
588
+ """Collect all appliances with relevant info."""
589
+ self._count = 0
590
+ self._all_locations()
591
+
592
+ if self._smile_legacy:
593
+ self._create_legacy_gateway()
594
+ # For legacy P1 collect the connected SmartMeter info
595
+ if self.smile_type == "power":
596
+ appl = Munch()
597
+ self._p1_smartmeter_info_finder(appl)
598
+ # Legacy P1 has no more devices
599
+ return
600
+
601
+ for appliance in self._appliances.findall("./appliance"):
602
+ appl = Munch()
603
+ appl.pwclass = appliance.find("type").text
604
+ # Skip thermostats that have this key, should be an orphaned device (Core #81712)
605
+ if (
606
+ appl.pwclass == "thermostat"
607
+ and appliance.find("actuator_functionalities/") is None
608
+ ):
609
+ continue
610
+
611
+ appl.location = None
612
+ if (appl_loc := appliance.find("location")) is not None:
613
+ appl.location = appl_loc.attrib["id"]
614
+ # Provide a location for legacy_anna, also don't assign the _home_location
615
+ # to thermostat-devices without a location, they are not active
616
+ elif (
617
+ self._smile_legacy and self.smile_type == "thermostat"
618
+ ) or appl.pwclass not in THERMOSTAT_CLASSES:
619
+ appl.location = self._home_location
620
+
621
+ appl.dev_id = appliance.attrib["id"]
622
+ appl.name = appliance.find("name").text
623
+ appl.model = appl.pwclass.replace("_", " ").title()
624
+ appl.firmware = None
625
+ appl.hardware = None
626
+ appl.mac = None
627
+ appl.zigbee_mac = None
628
+ appl.vendor_name = None
629
+
630
+ # Determine class for this appliance
631
+ # Skip on heater_central when no active device present or on orphaned stretch devices
632
+ if not (appl := self._appliance_info_finder(appliance, appl)):
633
+ continue
634
+
635
+ # Skip orphaned heater_central (Core Issue #104433)
636
+ if appl.pwclass == "heater_central" and appl.dev_id != self._heater_id:
637
+ continue
638
+
639
+ # P1: for gateway and smartmeter switch device_id - part 1
640
+ # This is done to avoid breakage in HA Core
641
+ if appl.pwclass == "gateway" and self.smile_type == "power":
642
+ appl.dev_id = appl.location
643
+
644
+ # Don't show orphaned non-legacy thermostat-types or the OpenTherm Gateway.
645
+ if (
646
+ not self._smile_legacy
647
+ and appl.pwclass in THERMOSTAT_CLASSES
648
+ and appl.location is None
649
+ ):
650
+ continue
651
+
652
+ self.gw_devices[appl.dev_id] = {"dev_class": appl.pwclass}
653
+ self._count += 1
654
+ for key, value in {
655
+ "firmware": appl.firmware,
656
+ "hardware": appl.hardware,
657
+ "location": appl.location,
658
+ "mac_address": appl.mac,
659
+ "model": appl.model,
660
+ "name": appl.name,
661
+ "zigbee_mac_address": appl.zigbee_mac,
662
+ "vendor": appl.vendor_name,
663
+ }.items():
664
+ if value is not None or key == "location":
665
+ appl_key = cast(ApplianceType, key)
666
+ self.gw_devices[appl.dev_id][appl_key] = value
667
+ self._count += 1
668
+
669
+ # For non-legacy P1 collect the connected SmartMeter info
670
+ if self.smile_type == "power":
671
+ self._p1_smartmeter_info_finder(appl)
672
+ # P1: for gateway and smartmeter switch device_id - part 2
673
+ for item in self.gw_devices:
674
+ if item != self.gateway_id:
675
+ self.gateway_id = item
676
+ # Leave for-loop to avoid a 2nd device_id switch
677
+ break
678
+
679
+ # Place the gateway and optional heater_central devices as 1st and 2nd
680
+ for dev_class in ("heater_central", "gateway"):
681
+ for dev_id, device in dict(self.gw_devices).items():
682
+ if device["dev_class"] == dev_class:
683
+ tmp_device = device
684
+ self.gw_devices.pop(dev_id)
685
+ cleared_dict = self.gw_devices
686
+ add_to_front = {dev_id: tmp_device}
687
+ self.gw_devices = {**add_to_front, **cleared_dict}
688
+
689
+ def _match_locations(self) -> dict[str, ThermoLoc]:
690
+ """Helper-function for _scan_thermostats().
691
+
692
+ Match appliances with locations.
693
+ """
694
+ matched_locations: dict[str, ThermoLoc] = {}
695
+ for location_id, location_details in self._loc_data.items():
696
+ for appliance_details in self.gw_devices.values():
697
+ if appliance_details["location"] == location_id:
698
+ location_details.update(
699
+ {"master": None, "master_prio": 0, "slaves": set()}
700
+ )
701
+ matched_locations[location_id] = location_details
702
+
703
+ return matched_locations
704
+
705
+ def _control_state(self, loc_id: str) -> str | bool:
706
+ """Helper-function for _device_data_adam().
707
+
708
+ Adam: find the thermostat control_state of a location, from DOMAIN_OBJECTS.
709
+ Represents the heating/cooling demand-state of the local master thermostat.
710
+ Note: heating or cooling can still be active when the setpoint has been reached.
711
+ """
712
+ locator = f'location[@id="{loc_id}"]'
713
+ if (location := self._domain_objects.find(locator)) is not None:
714
+ locator = './actuator_functionalities/thermostat_functionality[type="thermostat"]/control_state'
715
+ if (ctrl_state := location.find(locator)) is not None:
716
+ return str(ctrl_state.text)
717
+
718
+ return False
719
+
720
+ def _presets_legacy(self) -> dict[str, list[float]]:
721
+ """Helper-function for presets() - collect Presets for a legacy Anna."""
722
+ presets: dict[str, list[float]] = {}
723
+ for directive in self._domain_objects.findall("rule/directives/when/then"):
724
+ if directive is not None and directive.get("icon") is not None:
725
+ # Ensure list of heating_setpoint, cooling_setpoint
726
+ presets[directive.attrib["icon"]] = [
727
+ float(directive.attrib["temperature"]),
728
+ 0,
729
+ ]
730
+
731
+ return presets
732
+
733
+ def _presets(self, loc_id: str) -> dict[str, list[float]]:
734
+ """Collect Presets for a Thermostat based on location_id."""
735
+ presets: dict[str, list[float]] = {}
736
+ tag_1 = "zone_setpoint_and_state_based_on_preset"
737
+ tag_2 = "Thermostat presets"
738
+
739
+ if self._smile_legacy:
740
+ return self._presets_legacy()
741
+
742
+ if not (rule_ids := self._rule_ids_by_tag(tag_1, loc_id)):
743
+ if not (rule_ids := self._rule_ids_by_name(tag_2, loc_id)):
744
+ return presets # pragma: no cover
745
+
746
+ for rule_id in rule_ids:
747
+ directives: etree = self._domain_objects.find(
748
+ f'rule[@id="{rule_id}"]/directives'
749
+ )
750
+ for directive in directives:
751
+ preset = directive.find("then").attrib
752
+ presets[directive.attrib["preset"]] = [
753
+ float(preset["heating_setpoint"]),
754
+ float(preset["cooling_setpoint"]),
755
+ ]
756
+
757
+ return presets
758
+
759
+ def _rule_ids_by_name(self, name: str, loc_id: str) -> dict[str, str]:
760
+ """Helper-function for _presets().
761
+
762
+ Obtain the rule_id from the given name and and provide the location_id, when present.
763
+ """
764
+ schedule_ids: dict[str, str] = {}
765
+ locator = f'./contexts/context/zone/location[@id="{loc_id}"]'
766
+ for rule in self._domain_objects.findall(f'./rule[name="{name}"]'):
767
+ if rule.find(locator) is not None:
768
+ schedule_ids[rule.attrib["id"]] = loc_id
769
+ else:
770
+ schedule_ids[rule.attrib["id"]] = NONE
771
+
772
+ return schedule_ids
773
+
774
+ def _rule_ids_by_tag(self, tag: str, loc_id: str) -> dict[str, str]:
775
+ """Helper-function for _presets(), _schedules() and _last_active_schedule().
776
+
777
+ Obtain the rule_id from the given template_tag and provide the location_id, when present.
778
+ """
779
+ schedule_ids: dict[str, str] = {}
780
+ locator1 = f'./template[@tag="{tag}"]'
781
+ locator2 = f'./contexts/context/zone/location[@id="{loc_id}"]'
782
+ for rule in self._domain_objects.findall("./rule"):
783
+ if rule.find(locator1) is not None:
784
+ if rule.find(locator2) is not None:
785
+ schedule_ids[rule.attrib["id"]] = loc_id
786
+ else:
787
+ schedule_ids[rule.attrib["id"]] = NONE
788
+
789
+ return schedule_ids
790
+
791
+ def _appliance_measurements(
792
+ self,
793
+ appliance: etree,
794
+ data: DeviceData,
795
+ measurements: dict[str, DATA | UOM],
796
+ ) -> None:
797
+ """Helper-function for _get_measurement_data() - collect appliance measurement data."""
798
+ for measurement, attrs in measurements.items():
799
+ p_locator = f'.//logs/point_log[type="{measurement}"]/period/measurement'
800
+ if (appl_p_loc := appliance.find(p_locator)) is not None:
801
+ if self._smile_legacy and measurement == "domestic_hot_water_state":
802
+ continue
803
+
804
+ # Skip known obsolete measurements
805
+ updated_date_locator = (
806
+ f'.//logs/point_log[type="{measurement}"]/updated_date'
807
+ )
808
+ if measurement in OBSOLETE_MEASUREMENTS:
809
+ if (
810
+ updated_date_key := appliance.find(updated_date_locator)
811
+ ) is not None:
812
+ updated_date = updated_date_key.text.split("T")[0]
813
+ date_1 = dt.datetime.strptime(updated_date, "%Y-%m-%d")
814
+ date_2 = dt.datetime.now()
815
+ if int((date_2 - date_1).days) > 7:
816
+ continue
817
+
818
+ if new_name := getattr(attrs, ATTR_NAME, None):
819
+ measurement = new_name
820
+
821
+ match measurement:
822
+ # measurements with states "on" or "off" that need to be passed directly
823
+ case "select_dhw_mode":
824
+ data["select_dhw_mode"] = appl_p_loc.text
825
+ case _ as measurement if measurement in BINARY_SENSORS:
826
+ bs_key = cast(BinarySensorType, measurement)
827
+ bs_value = appl_p_loc.text in ["on", "true"]
828
+ data["binary_sensors"][bs_key] = bs_value
829
+ case _ as measurement if measurement in SENSORS:
830
+ s_key = cast(SensorType, measurement)
831
+ s_value = format_measure(
832
+ appl_p_loc.text, getattr(attrs, ATTR_UNIT_OF_MEASUREMENT)
833
+ )
834
+ data["sensors"][s_key] = s_value
835
+ # Anna: save cooling-related measurements for later use
836
+ # Use the local outdoor temperature as reference for turning cooling on/off
837
+ if measurement == "cooling_activation_outdoor_temperature":
838
+ self._cooling_activation_outdoor_temp = data["sensors"][
839
+ "cooling_activation_outdoor_temperature"
840
+ ]
841
+ if measurement == "cooling_deactivation_threshold":
842
+ self._cooling_deactivation_threshold = data["sensors"][
843
+ "cooling_deactivation_threshold"
844
+ ]
845
+ if measurement == "outdoor_air_temperature":
846
+ self._outdoor_temp = data["sensors"][
847
+ "outdoor_air_temperature"
848
+ ]
849
+ case _ as measurement if measurement in SWITCHES:
850
+ sw_key = cast(SwitchType, measurement)
851
+ sw_value = appl_p_loc.text in ["on", "true"]
852
+ data["switches"][sw_key] = sw_value
853
+ case "c_heating_state":
854
+ value = appl_p_loc.text in ["on", "true"]
855
+ data["c_heating_state"] = value
856
+ case "elga_status_code":
857
+ data["elga_status_code"] = int(appl_p_loc.text)
858
+
859
+ i_locator = f'.//logs/interval_log[type="{measurement}"]/period/measurement'
860
+ if (appl_i_loc := appliance.find(i_locator)) is not None:
861
+ name = cast(SensorType, f"{measurement}_interval")
862
+ data["sensors"][name] = format_measure(
863
+ appl_i_loc.text, ENERGY_WATT_HOUR
864
+ )
865
+
866
+ self._count += len(data["binary_sensors"])
867
+ self._count += len(data["sensors"])
868
+ self._count += len(data["switches"])
869
+ # Don't count the above top-level dicts, only the remaining single items
870
+ self._count += len(data) - 3
871
+
872
+ def _wireless_availablity(self, appliance: etree, data: DeviceData) -> None:
873
+ """Helper-function for _get_measurement_data().
874
+
875
+ Collect the availablity-status for wireless connected devices.
876
+ """
877
+ if self.smile(ADAM):
878
+ # Collect for Plugs
879
+ locator = "./logs/interval_log/electricity_interval_meter"
880
+ mod_type = "electricity_interval_meter"
881
+ module_data = self._get_module_data(appliance, locator, mod_type)
882
+ if module_data["reachable"] is None:
883
+ # Collect for wireless thermostats
884
+ locator = "./logs/point_log[type='thermostat']/thermostat"
885
+ mod_type = "thermostat"
886
+ module_data = self._get_module_data(appliance, locator, mod_type)
887
+
888
+ if module_data["reachable"] is not None:
889
+ data["available"] = module_data["reachable"]
890
+ self._count += 1
891
+
892
+ def _get_appliances_with_offset_functionality(self) -> list[str]:
893
+ """Helper-function collecting all appliance that have offset_functionality."""
894
+ therm_list: list[str] = []
895
+ offset_appls = self._appliances.findall(
896
+ './/actuator_functionalities/offset_functionality[type="temperature_offset"]/offset/../../..'
897
+ )
898
+ for item in offset_appls:
899
+ therm_list.append(item.attrib["id"])
900
+
901
+ return therm_list
902
+
903
+ def _get_actuator_functionalities(
904
+ self, xml: etree, device: DeviceData, data: DeviceData
905
+ ) -> None:
906
+ """Helper-function for _get_measurement_data()."""
907
+ for item in ACTIVE_ACTUATORS:
908
+ # Skip max_dhw_temperature, not initially valid,
909
+ # skip thermostat for thermo_sensors
910
+ if item == "max_dhw_temperature" or (
911
+ item == "thermostat" and device["dev_class"] == "thermo_sensor"
912
+ ):
913
+ continue
914
+
915
+ temp_dict: ActuatorData = {}
916
+ functionality = "thermostat_functionality"
917
+ if item == "temperature_offset":
918
+ functionality = "offset_functionality"
919
+ # Don't support temperature_offset for legacy Anna
920
+ if self._smile_legacy:
921
+ continue
922
+
923
+ # When there is no updated_date-text, skip the actuator
924
+ updated_date_location = f'.//actuator_functionalities/{functionality}[type="{item}"]/updated_date'
925
+ if (
926
+ updated_date_key := xml.find(updated_date_location)
927
+ ) is not None and updated_date_key.text is None:
928
+ continue
929
+
930
+ for key in LIMITS:
931
+ locator = (
932
+ f'.//actuator_functionalities/{functionality}[type="{item}"]/{key}'
933
+ )
934
+ if (function := xml.find(locator)) is not None:
935
+ if key == "offset":
936
+ # Add limits and resolution for temperature_offset,
937
+ # not provided by Plugwise in the XML data
938
+ temp_dict["lower_bound"] = -2.0
939
+ temp_dict["resolution"] = 0.1
940
+ temp_dict["upper_bound"] = 2.0
941
+ self._count += 3
942
+ # Rename offset to setpoint
943
+ key = "setpoint"
944
+
945
+ act_key = cast(ActuatorDataType, key)
946
+ temp_dict[act_key] = format_measure(function.text, TEMP_CELSIUS)
947
+ self._count += 1
948
+
949
+ if temp_dict:
950
+ # If domestic_hot_water_setpoint is present as actuator,
951
+ # rename and remove as sensor
952
+ if item == DHW_SETPOINT:
953
+ item = "max_dhw_temperature"
954
+ if DHW_SETPOINT in data["sensors"]:
955
+ data["sensors"].pop(DHW_SETPOINT)
956
+ self._count -= 1
957
+
958
+ act_item = cast(ActuatorType, item)
959
+ data[act_item] = temp_dict
960
+
961
+ def _get_regulation_mode(self, appliance: etree, data: DeviceData) -> None:
962
+ """Helper-function for _get_measurement_data().
963
+
964
+ Collect the gateway regulation_mode.
965
+ """
966
+ locator = "./actuator_functionalities/regulation_mode_control_functionality"
967
+ if (search := appliance.find(locator)) is not None:
968
+ data["select_regulation_mode"] = search.find("mode").text
969
+ self._count += 1
970
+ self._cooling_enabled = data["select_regulation_mode"] == "cooling"
971
+
972
+ def _cleanup_data(self, data: DeviceData) -> None:
973
+ """Helper-function for _get_measurement_data().
974
+
975
+ Clean up the data dict.
976
+ """
977
+ # Don't show cooling-related when no cooling present,
978
+ # but, keep cooling_enabled for Elga
979
+ if not self._cooling_present:
980
+ if "cooling_state" in data["binary_sensors"]:
981
+ data["binary_sensors"].pop("cooling_state")
982
+ self._count -= 1
983
+ if "cooling_ena_switch" in data["switches"]:
984
+ data["switches"].pop("cooling_ena_switch") # pragma: no cover
985
+ self._count -= 1 # pragma: no cover
986
+ if not self._elga and "cooling_enabled" in data:
987
+ data.pop("cooling_enabled") # pragma: no cover
988
+ self._count -= 1 # pragma: no cover
989
+
990
+ def _process_c_heating_state(self, data: DeviceData) -> None:
991
+ """Helper-function for _get_measurement_data().
992
+
993
+ Process the central_heating_state value.
994
+ """
995
+ if self._on_off_device:
996
+ # Anna + OnOff heater: use central_heating_state to show heating_state
997
+ # Solution for Core issue #81839
998
+ if self.smile(ANNA):
999
+ data["binary_sensors"]["heating_state"] = data["c_heating_state"]
1000
+
1001
+ # Adam + OnOff cooling: use central_heating_state to show heating/cooling_state
1002
+ if self.smile(ADAM):
1003
+ if "heating_state" not in data["binary_sensors"]:
1004
+ self._count += 1
1005
+ data["binary_sensors"]["heating_state"] = False
1006
+ if "cooling_state" not in data["binary_sensors"]:
1007
+ self._count += 1
1008
+ data["binary_sensors"]["cooling_state"] = False
1009
+ if self._cooling_enabled:
1010
+ data["binary_sensors"]["cooling_state"] = data["c_heating_state"]
1011
+ else:
1012
+ data["binary_sensors"]["heating_state"] = data["c_heating_state"]
1013
+
1014
+ # Anna + Elga: use central_heating_state to show heating_state
1015
+ if self._elga:
1016
+ data["binary_sensors"]["heating_state"] = data["c_heating_state"]
1017
+
1018
+ def _get_measurement_data(self, dev_id: str) -> DeviceData:
1019
+ """Helper-function for smile.py: _get_device_data().
1020
+
1021
+ Collect the appliance-data based on device id.
1022
+ """
1023
+ data: DeviceData = {"binary_sensors": {}, "sensors": {}, "switches": {}}
1024
+ # Get P1 smartmeter data from LOCATIONS or MODULES
1025
+ device = self.gw_devices[dev_id]
1026
+ # !! DON'T CHANGE below two if-lines, will break stuff !!
1027
+ if self.smile_type == "power":
1028
+ if device["dev_class"] == "smartmeter":
1029
+ if not self._smile_legacy:
1030
+ data.update(self._power_data_from_location(device["location"]))
1031
+ else:
1032
+ data.update(self._power_data_from_modules())
1033
+
1034
+ return data
1035
+
1036
+ # Get non-p1 data from APPLIANCES, for legacy from DOMAIN_OBJECTS.
1037
+ measurements = DEVICE_MEASUREMENTS
1038
+ if self._is_thermostat and dev_id == self._heater_id:
1039
+ measurements = HEATER_CENTRAL_MEASUREMENTS
1040
+ # Show the allowed dhw_modes (Loria only)
1041
+ if self._dhw_allowed_modes:
1042
+ data["dhw_modes"] = self._dhw_allowed_modes
1043
+ # Counting of this item is done in _appliance_measurements()
1044
+
1045
+ if (
1046
+ appliance := self._appliances.find(f'./appliance[@id="{dev_id}"]')
1047
+ ) is not None:
1048
+ self._appliance_measurements(appliance, data, measurements)
1049
+ self._get_lock_state(appliance, data)
1050
+
1051
+ for toggle, name in TOGGLES.items():
1052
+ self._get_toggle_state(appliance, toggle, name, data)
1053
+
1054
+ if appliance.find("type").text in ACTUATOR_CLASSES:
1055
+ self._get_actuator_functionalities(appliance, device, data)
1056
+
1057
+ # Collect availability-status for wireless connected devices to Adam
1058
+ self._wireless_availablity(appliance, data)
1059
+
1060
+ if dev_id == self.gateway_id and self.smile(ADAM):
1061
+ self._get_regulation_mode(appliance, data)
1062
+
1063
+ # Adam & Anna: the Smile outdoor_temperature is present in DOMAIN_OBJECTS and LOCATIONS - under Home
1064
+ # The outdoor_temperature present in APPLIANCES is a local sensor connected to the active device
1065
+ if self._is_thermostat and dev_id == self.gateway_id:
1066
+ outdoor_temperature = self._object_value(
1067
+ self._home_location, "outdoor_temperature"
1068
+ )
1069
+ if outdoor_temperature is not None:
1070
+ data.update({"sensors": {"outdoor_temperature": outdoor_temperature}})
1071
+ self._count += 1
1072
+
1073
+ if "c_heating_state" in data:
1074
+ self._process_c_heating_state(data)
1075
+ # Remove c_heating_state after processing
1076
+ data.pop("c_heating_state")
1077
+ self._count -= 1
1078
+
1079
+ if self._is_thermostat and self.smile(ANNA) and dev_id == self._heater_id:
1080
+ # Anna+Elga: base cooling_state on the elga-status-code
1081
+ if "elga_status_code" in data:
1082
+ # Techneco Elga has cooling-capability
1083
+ self._cooling_present = True
1084
+ data["model"] = "Generic heater/cooler"
1085
+ self._cooling_enabled = data["elga_status_code"] in [8, 9]
1086
+ data["binary_sensors"]["cooling_state"] = self._cooling_active = (
1087
+ data["elga_status_code"] == 8
1088
+ )
1089
+ data.pop("elga_status_code", None)
1090
+ self._count -= 1
1091
+ # Elga has no cooling-switch
1092
+ if "cooling_ena_switch" in data["switches"]:
1093
+ data["switches"].pop("cooling_ena_switch")
1094
+ self._count -= 1
1095
+
1096
+ # Loria/Thermastage: cooling-related is based on cooling_state
1097
+ # and modulation_level
1098
+ elif self._cooling_present and "cooling_state" in data["binary_sensors"]:
1099
+ self._cooling_enabled = data["binary_sensors"]["cooling_state"]
1100
+ self._cooling_active = data["sensors"]["modulation_level"] == 100
1101
+ # For Loria the above does not work (pw-beta issue #301)
1102
+ if "cooling_ena_switch" in data["switches"]:
1103
+ self._cooling_enabled = data["switches"]["cooling_ena_switch"]
1104
+ self._cooling_active = data["binary_sensors"]["cooling_state"]
1105
+
1106
+ self._cleanup_data(data)
1107
+
1108
+ return data
1109
+
1110
+ def _rank_thermostat(
1111
+ self,
1112
+ thermo_matching: dict[str, int],
1113
+ loc_id: str,
1114
+ appliance_id: str,
1115
+ appliance_details: DeviceData,
1116
+ ) -> None:
1117
+ """Helper-function for _scan_thermostats().
1118
+
1119
+ Rank the thermostat based on appliance_details: master or slave.
1120
+ """
1121
+ appl_class = appliance_details["dev_class"]
1122
+ appl_d_loc = appliance_details["location"]
1123
+ if loc_id == appl_d_loc and appl_class in thermo_matching:
1124
+ # Pre-elect new master
1125
+ if thermo_matching[appl_class] > self._thermo_locs[loc_id]["master_prio"]:
1126
+ # Demote former master
1127
+ if (tl_master := self._thermo_locs[loc_id]["master"]) is not None:
1128
+ self._thermo_locs[loc_id]["slaves"].add(tl_master)
1129
+
1130
+ # Crown master
1131
+ self._thermo_locs[loc_id]["master_prio"] = thermo_matching[appl_class]
1132
+ self._thermo_locs[loc_id]["master"] = appliance_id
1133
+
1134
+ else:
1135
+ self._thermo_locs[loc_id]["slaves"].add(appliance_id)
1136
+
1137
+ def _scan_thermostats(self) -> None:
1138
+ """Helper-function for smile.py: get_all_devices().
1139
+
1140
+ Update locations with thermostat ranking results and use
1141
+ the result to update the device_class of slave thermostats.
1142
+ """
1143
+ self._thermo_locs = self._match_locations()
1144
+
1145
+ thermo_matching: dict[str, int] = {
1146
+ "thermostat": 3,
1147
+ "zone_thermometer": 2,
1148
+ "zone_thermostat": 2,
1149
+ "thermostatic_radiator_valve": 1,
1150
+ }
1151
+
1152
+ for loc_id in self._thermo_locs:
1153
+ for dev_id, device in self.gw_devices.items():
1154
+ self._rank_thermostat(thermo_matching, loc_id, dev_id, device)
1155
+
1156
+ # Update slave thermostat class where needed
1157
+ for dev_id, device in self.gw_devices.items():
1158
+ if (loc_id := device["location"]) in self._thermo_locs:
1159
+ tl_loc_id = self._thermo_locs[loc_id]
1160
+ if "slaves" in tl_loc_id and dev_id in tl_loc_id["slaves"]:
1161
+ device["dev_class"] = "thermo_sensor"
1162
+
1163
+ def _thermostat_uri_legacy(self) -> str:
1164
+ """Helper-function for _thermostat_uri().
1165
+
1166
+ Determine the location-set_temperature uri - from APPLIANCES.
1167
+ """
1168
+ locator = "./appliance[type='thermostat']"
1169
+ appliance_id = self._appliances.find(locator).attrib["id"]
1170
+
1171
+ return f"{APPLIANCES};id={appliance_id}/thermostat"
1172
+
1173
+ def _thermostat_uri(self, loc_id: str) -> str:
1174
+ """Helper-function for smile.py: set_temperature().
1175
+
1176
+ Determine the location-set_temperature uri - from LOCATIONS.
1177
+ """
1178
+ if self._smile_legacy:
1179
+ return self._thermostat_uri_legacy()
1180
+
1181
+ locator = f'./location[@id="{loc_id}"]/actuator_functionalities/thermostat_functionality'
1182
+ thermostat_functionality_id = self._locations.find(locator).attrib["id"]
1183
+
1184
+ return f"{LOCATIONS};id={loc_id}/thermostat;id={thermostat_functionality_id}"
1185
+
1186
+ def _get_group_switches(self) -> dict[str, DeviceData]:
1187
+ """Helper-function for smile.py: get_all_devices().
1188
+
1189
+ Collect switching- or pump-group info.
1190
+ """
1191
+ switch_groups: dict[str, DeviceData] = {}
1192
+ # P1 and Anna don't have switchgroups
1193
+ if self.smile_type == "power" or self.smile(ANNA):
1194
+ return switch_groups
1195
+
1196
+ for group in self._domain_objects.findall("./group"):
1197
+ members: list[str] = []
1198
+ group_id = group.attrib["id"]
1199
+ group_name = group.find("name").text
1200
+ group_type = group.find("type").text
1201
+ group_appliances = group.findall("appliances/appliance")
1202
+ for item in group_appliances:
1203
+ # Check if members are not orphaned - stretch
1204
+ if item.attrib["id"] in self.gw_devices:
1205
+ members.append(item.attrib["id"])
1206
+
1207
+ if group_type in SWITCH_GROUP_TYPES and members:
1208
+ switch_groups.update(
1209
+ {
1210
+ group_id: {
1211
+ "dev_class": group_type,
1212
+ "model": "Switchgroup",
1213
+ "name": group_name,
1214
+ "members": members,
1215
+ },
1216
+ },
1217
+ )
1218
+ self._count += 4
1219
+
1220
+ return switch_groups
1221
+
1222
+ def _heating_valves(self) -> int | bool:
1223
+ """Helper-function for smile.py: _device_data_adam().
1224
+
1225
+ Collect amount of open valves indicating active direct heating.
1226
+ For cases where the heat is provided from an external shared source (city heating).
1227
+ """
1228
+ loc_found: int = 0
1229
+ open_valve_count: int = 0
1230
+ for appliance in self._appliances.findall("./appliance"):
1231
+ locator = './logs/point_log[type="valve_position"]/period/measurement'
1232
+ if (appl_loc := appliance.find(locator)) is not None:
1233
+ loc_found += 1
1234
+ if float(appl_loc.text) > 0.0:
1235
+ open_valve_count += 1
1236
+
1237
+ return False if loc_found == 0 else open_valve_count
1238
+
1239
+ def power_data_energy_diff(
1240
+ self,
1241
+ measurement: str,
1242
+ net_string: SensorType,
1243
+ f_val: float | int,
1244
+ direct_data: DeviceData,
1245
+ ) -> DeviceData:
1246
+ """Calculate differential energy."""
1247
+ if (
1248
+ "electricity" in measurement
1249
+ and "phase" not in measurement
1250
+ and "interval" not in net_string
1251
+ ):
1252
+ diff = 1
1253
+ if "produced" in measurement:
1254
+ diff = -1
1255
+ if net_string not in direct_data["sensors"]:
1256
+ tmp_val: float | int = 0
1257
+ else:
1258
+ tmp_val = direct_data["sensors"][net_string]
1259
+
1260
+ if isinstance(f_val, int):
1261
+ tmp_val += f_val * diff
1262
+ else:
1263
+ tmp_val += float(f_val * diff)
1264
+ tmp_val = float(f"{round(tmp_val, 3):.3f}")
1265
+
1266
+ direct_data["sensors"][net_string] = tmp_val
1267
+
1268
+ return direct_data
1269
+
1270
+ def _power_data_peak_value(self, direct_data: DeviceData, loc: Munch) -> Munch:
1271
+ """Helper-function for _power_data_from_location() and _power_data_from_modules()."""
1272
+ loc.found = True
1273
+ # If locator not found look for P1 gas_consumed or phase data (without tariff)
1274
+ # or for P1 legacy electricity_point_meter or gas_*_meter data
1275
+ if loc.logs.find(loc.locator) is None:
1276
+ if "log" in loc.log_type and (
1277
+ "gas" in loc.measurement or "phase" in loc.measurement
1278
+ ):
1279
+ # Avoid double processing by skipping one peak-list option
1280
+ if loc.peak_select == "nl_offpeak":
1281
+ loc.found = False
1282
+ return loc
1283
+
1284
+ loc.locator = (
1285
+ f'./{loc.log_type}[type="{loc.measurement}"]/period/measurement'
1286
+ )
1287
+ if loc.logs.find(loc.locator) is None:
1288
+ loc.found = False
1289
+ return loc
1290
+ # P1 legacy point_meter has no tariff_indicator
1291
+ elif "meter" in loc.log_type and (
1292
+ "point" in loc.log_type or "gas" in loc.measurement
1293
+ ):
1294
+ # Avoid double processing by skipping one peak-list option
1295
+ if loc.peak_select == "nl_offpeak":
1296
+ loc.found = False
1297
+ return loc
1298
+
1299
+ loc.locator = (
1300
+ f"./{loc.meas_list[0]}_{loc.log_type}/"
1301
+ f'measurement[@directionality="{loc.meas_list[1]}"]'
1302
+ )
1303
+ if loc.logs.find(loc.locator) is None:
1304
+ loc.found = False
1305
+ return loc
1306
+ else:
1307
+ loc.found = False
1308
+ return loc
1309
+
1310
+ if (peak := loc.peak_select.split("_")[1]) == "offpeak":
1311
+ peak = "off_peak"
1312
+ log_found = loc.log_type.split("_")[0]
1313
+ loc.key_string = f"{loc.measurement}_{peak}_{log_found}"
1314
+ if "gas" in loc.measurement or loc.log_type == "point_meter":
1315
+ loc.key_string = f"{loc.measurement}_{log_found}"
1316
+ if "phase" in loc.measurement:
1317
+ loc.key_string = f"{loc.measurement}"
1318
+ loc.net_string = f"net_electricity_{log_found}"
1319
+ val = loc.logs.find(loc.locator).text
1320
+ loc.f_val = power_data_local_format(loc.attrs, loc.key_string, val)
1321
+
1322
+ return loc
1323
+
1324
+ def _power_data_from_location(self, loc_id: str) -> DeviceData:
1325
+ """Helper-function for smile.py: _get_device_data().
1326
+
1327
+ Collect the power-data based on Location ID, from LOCATIONS.
1328
+ """
1329
+ direct_data: DeviceData = {"sensors": {}}
1330
+ loc = Munch()
1331
+ log_list: list[str] = ["point_log", "cumulative_log", "interval_log"]
1332
+ peak_list: list[str] = ["nl_peak", "nl_offpeak"]
1333
+ t_string = "tariff"
1334
+
1335
+ search = self._locations
1336
+ loc.logs = search.find(f'./location[@id="{loc_id}"]/logs')
1337
+ for loc.measurement, loc.attrs in P1_MEASUREMENTS.items():
1338
+ for loc.log_type in log_list:
1339
+ for loc.peak_select in peak_list:
1340
+ # meter_string = ".//{}[type='{}']/"
1341
+ loc.locator = (
1342
+ f'./{loc.log_type}[type="{loc.measurement}"]/period/'
1343
+ f'measurement[@{t_string}="{loc.peak_select}"]'
1344
+ )
1345
+ loc = self._power_data_peak_value(direct_data, loc)
1346
+ if not loc.found:
1347
+ continue
1348
+
1349
+ direct_data = self.power_data_energy_diff(
1350
+ loc.measurement, loc.net_string, loc.f_val, direct_data
1351
+ )
1352
+ key = cast(SensorType, loc.key_string)
1353
+ direct_data["sensors"][key] = loc.f_val
1354
+
1355
+ self._count += len(direct_data["sensors"])
1356
+ return direct_data
1357
+
1358
+ def _power_data_from_modules(self) -> DeviceData:
1359
+ """Helper-function for smile.py: _get_device_data().
1360
+
1361
+ Collect the power-data from MODULES (P1 legacy only).
1362
+ """
1363
+ direct_data: DeviceData = {"sensors": {}}
1364
+ loc = Munch()
1365
+ mod_list: list[str] = ["interval_meter", "cumulative_meter", "point_meter"]
1366
+ peak_list: list[str] = ["nl_peak", "nl_offpeak"]
1367
+ t_string = "tariff_indicator"
1368
+
1369
+ search = self._modules
1370
+ mod_logs = search.findall("./module/services")
1371
+ for loc.measurement, loc.attrs in P1_LEGACY_MEASUREMENTS.items():
1372
+ loc.meas_list = loc.measurement.split("_")
1373
+ for loc.logs in mod_logs:
1374
+ for loc.log_type in mod_list:
1375
+ for loc.peak_select in peak_list:
1376
+ loc.locator = (
1377
+ f"./{loc.meas_list[0]}_{loc.log_type}/measurement"
1378
+ f'[@directionality="{loc.meas_list[1]}"][@{t_string}="{loc.peak_select}"]'
1379
+ )
1380
+ loc = self._power_data_peak_value(direct_data, loc)
1381
+ if not loc.found:
1382
+ continue
1383
+
1384
+ direct_data = self.power_data_energy_diff(
1385
+ loc.measurement, loc.net_string, loc.f_val, direct_data
1386
+ )
1387
+ key = cast(SensorType, loc.key_string)
1388
+ direct_data["sensors"][key] = loc.f_val
1389
+
1390
+ self._count += len(direct_data["sensors"])
1391
+ return direct_data
1392
+
1393
+ def _preset(self, loc_id: str) -> str | None:
1394
+ """Helper-function for smile.py: device_data_climate().
1395
+
1396
+ Collect the active preset based on Location ID.
1397
+ """
1398
+ if not self._smile_legacy:
1399
+ locator = f'./location[@id="{loc_id}"]/preset'
1400
+ if (preset := self._domain_objects.find(locator)) is not None:
1401
+ return str(preset.text)
1402
+ return None
1403
+
1404
+ locator = "./rule[active='true']/directives/when/then"
1405
+ if (
1406
+ not (active_rule := etree_to_dict(self._domain_objects.find(locator)))
1407
+ or "icon" not in active_rule
1408
+ ):
1409
+ return None
1410
+
1411
+ return active_rule["icon"]
1412
+
1413
+ def _schedules_legacy(
1414
+ self,
1415
+ avail: list[str],
1416
+ location: str,
1417
+ sel: str,
1418
+ ) -> tuple[list[str], str]:
1419
+ """Helper-function for _schedules().
1420
+
1421
+ Collect available schedules/schedules for the legacy thermostat.
1422
+ """
1423
+ name: str | None = None
1424
+
1425
+ search = self._domain_objects
1426
+ for schedule in search.findall("./rule"):
1427
+ if rule_name := schedule.find("name").text:
1428
+ if "preset" not in rule_name:
1429
+ name = rule_name
1430
+
1431
+ log_type = "schedule_state"
1432
+ locator = f"./appliance[type='thermostat']/logs/point_log[type='{log_type}']/period/measurement"
1433
+ active = False
1434
+ if (result := search.find(locator)) is not None:
1435
+ active = result.text == "on"
1436
+
1437
+ if name is not None:
1438
+ avail = [name]
1439
+ if active:
1440
+ sel = name
1441
+
1442
+ self._last_active[location] = "".join(map(str, avail))
1443
+ return avail, sel
1444
+
1445
+ def _schedules(self, location: str) -> tuple[list[str], str]:
1446
+ """Helper-function for smile.py: _device_data_climate().
1447
+
1448
+ Obtain the available schedules/schedules. Adam: a schedule can be connected to more than one location.
1449
+ NEW: when a location_id is present then the schedule is active. Valid for both Adam and non-legacy Anna.
1450
+ """
1451
+ available: list[str] = [NONE]
1452
+ rule_ids: dict[str, str] = {}
1453
+ selected = NONE
1454
+
1455
+ # Legacy Anna schedule, only one schedule allowed
1456
+ if self._smile_legacy:
1457
+ return self._schedules_legacy(available, location, selected)
1458
+
1459
+ # Adam schedules, one schedule can be linked to various locations
1460
+ # self._last_active contains the locations and the active schedule name per location, or None
1461
+ if location not in self._last_active:
1462
+ self._last_active[location] = None
1463
+
1464
+ tag = "zone_preset_based_on_time_and_presence_with_override"
1465
+ if not (rule_ids := self._rule_ids_by_tag(tag, location)):
1466
+ return available, selected
1467
+
1468
+ schedules: list[str] = []
1469
+ for rule_id, loc_id in rule_ids.items():
1470
+ name = self._domain_objects.find(f'./rule[@id="{rule_id}"]/name').text
1471
+ locator = f'./rule[@id="{rule_id}"]/directives'
1472
+ # Show an empty schedule as no schedule found
1473
+ if not self._domain_objects.find(locator):
1474
+ continue # pragma: no cover
1475
+
1476
+ available.append(name)
1477
+ if location == loc_id:
1478
+ selected = name
1479
+ self._last_active[location] = selected
1480
+ schedules.append(name)
1481
+
1482
+ if schedules:
1483
+ available.remove(NONE)
1484
+ available.append(OFF)
1485
+ if self._last_active.get(location) is None:
1486
+ self._last_active[location] = self._last_used_schedule(schedules)
1487
+
1488
+ return available, selected
1489
+
1490
+ def _last_used_schedule(self, schedules: list[str]) -> str:
1491
+ """Helper-function for _schedules().
1492
+
1493
+ Determine the last-used schedule based on the modified date.
1494
+ """
1495
+ epoch = dt.datetime(1970, 1, 1, tzinfo=tz.tzutc())
1496
+ schedules_dates: dict[str, float] = {}
1497
+
1498
+ for name in schedules:
1499
+ result = self._domain_objects.find(f'./rule[name="{name}"]')
1500
+ schedule_date = result.find("modified_date").text
1501
+ schedule_time = parse(schedule_date)
1502
+ schedules_dates[name] = (schedule_time - epoch).total_seconds()
1503
+
1504
+ return sorted(schedules_dates.items(), key=lambda kv: kv[1])[-1][0]
1505
+
1506
+ def _object_value(self, obj_id: str, measurement: str) -> float | int | None:
1507
+ """Helper-function for smile.py: _get_device_data() and _device_data_anna().
1508
+
1509
+ Obtain the value/state for the given object from a location in DOMAIN_OBJECTS
1510
+ """
1511
+ val: float | int | None = None
1512
+ search = self._domain_objects
1513
+ locator = f'./location[@id="{obj_id}"]/logs/point_log[type="{measurement}"]/period/measurement'
1514
+ if (found := search.find(locator)) is not None:
1515
+ val = format_measure(found.text, NONE)
1516
+ return val
1517
+
1518
+ return val
1519
+
1520
+ def _get_lock_state(self, xml: etree, data: DeviceData) -> None:
1521
+ """Helper-function for _get_measurement_data().
1522
+
1523
+ Adam & Stretches: obtain the relay-switch lock state.
1524
+ """
1525
+ actuator = "actuator_functionalities"
1526
+ func_type = "relay_functionality"
1527
+ if self._stretch_v2:
1528
+ actuator = "actuators"
1529
+ func_type = "relay"
1530
+ if xml.find("type").text not in SPECIAL_PLUG_TYPES:
1531
+ locator = f"./{actuator}/{func_type}/lock"
1532
+ if (found := xml.find(locator)) is not None:
1533
+ data["switches"]["lock"] = found.text == "true"
1534
+ self._count += 1
1535
+
1536
+ def _get_toggle_state(
1537
+ self, xml: etree, toggle: str, name: ToggleNameType, data: DeviceData
1538
+ ) -> None:
1539
+ """Helper-function for _get_measurement_data().
1540
+
1541
+ Obtain the toggle state of a 'toggle' = switch.
1542
+ """
1543
+ if xml.find("type").text == "heater_central":
1544
+ locator = f"./actuator_functionalities/toggle_functionality[type='{toggle}']/state"
1545
+ if (state := xml.find(locator)) is not None:
1546
+ data["switches"][name] = state.text == "on"
1547
+ self._count += 1
1548
+ # Remove the cooling_enabled binary_sensor when the corresponding switch is present
1549
+ # Except for Elga
1550
+ if toggle == "cooling_enabled" and not self._elga:
1551
+ data["binary_sensors"].pop("cooling_enabled")
1552
+ self._count -= 1