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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
plugwise/helper.py CHANGED
@@ -8,32 +8,19 @@ import asyncio
8
8
  import datetime as dt
9
9
  from typing import cast
10
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 (
11
+ from plugwise.constants import (
22
12
  ACTIVE_ACTUATORS,
23
13
  ACTUATOR_CLASSES,
24
14
  ADAM,
25
15
  ANNA,
26
- APPLIANCES,
27
16
  ATTR_NAME,
28
17
  ATTR_UNIT_OF_MEASUREMENT,
29
18
  BINARY_SENSORS,
30
19
  DATA,
31
20
  DEVICE_MEASUREMENTS,
32
21
  DHW_SETPOINT,
33
- ENERGY_KILO_WATT_HOUR,
22
+ DOMAIN_OBJECTS,
34
23
  ENERGY_WATT_HOUR,
35
- FAKE_APPL,
36
- FAKE_LOC,
37
24
  HEATER_CENTRAL_MEASUREMENTS,
38
25
  LIMITS,
39
26
  LOCATIONS,
@@ -41,9 +28,7 @@ from .constants import (
41
28
  NONE,
42
29
  OBSOLETE_MEASUREMENTS,
43
30
  OFF,
44
- P1_LEGACY_MEASUREMENTS,
45
31
  P1_MEASUREMENTS,
46
- POWER_WATT,
47
32
  SENSORS,
48
33
  SPECIAL_PLUG_TYPES,
49
34
  SPECIALS,
@@ -67,43 +52,27 @@ from .constants import (
67
52
  ThermoLoc,
68
53
  ToggleNameType,
69
54
  )
70
- from .exceptions import (
55
+ from plugwise.exceptions import (
71
56
  ConnectionFailedError,
72
57
  InvalidAuthentication,
73
58
  InvalidXMLError,
74
59
  ResponseError,
75
60
  )
76
- from .util import escape_illegal_xml_characters, format_measure, version_to_model
77
-
78
-
79
- def check_model(name: str | None, vendor_name: str | None) -> str | None:
80
- """Model checking before using version_to_model."""
81
- if vendor_name == "Plugwise" and ((model := version_to_model(name)) != "Unknown"):
82
- return model
83
-
84
- return name
85
-
86
-
87
- def etree_to_dict(element: etree) -> dict[str, str]:
88
- """Helper-function translating xml Element to dict."""
89
- node: dict[str, str] = {}
90
- if element is not None:
91
- node.update(element.items())
92
-
93
- return node
94
-
61
+ from plugwise.util import (
62
+ check_model,
63
+ escape_illegal_xml_characters,
64
+ format_measure,
65
+ power_data_local_format,
66
+ )
95
67
 
96
- def power_data_local_format(
97
- attrs: dict[str, str], key_string: str, val: str
98
- ) -> float | int:
99
- """Format power data."""
100
- # Special formatting of P1_MEASUREMENT POWER_WATT values, do not move to util-format_measure() function!
101
- if all(item in key_string for item in ("electricity", "cumulative")):
102
- return format_measure(val, ENERGY_KILO_WATT_HOUR)
103
- if (attrs_uom := getattr(attrs, ATTR_UNIT_OF_MEASUREMENT)) == POWER_WATT:
104
- return int(round(float(val)))
68
+ # This way of importing aiohttp is because of patch/mocking in testing (aiohttp timeouts)
69
+ from aiohttp import BasicAuth, ClientError, ClientResponse, ClientSession, ClientTimeout
105
70
 
106
- return format_measure(val, attrs_uom)
71
+ # Time related
72
+ from dateutil import tz
73
+ from dateutil.parser import parse
74
+ from defusedxml import ElementTree as etree
75
+ from munch import Munch
107
76
 
108
77
 
109
78
  class SmileComm:
@@ -113,10 +82,10 @@ class SmileComm:
113
82
  self,
114
83
  host: str,
115
84
  password: str,
85
+ websession: ClientSession | None,
116
86
  username: str,
117
87
  port: int,
118
88
  timeout: float,
119
- websession: ClientSession | None,
120
89
  ) -> None:
121
90
  """Set the constructor for this class."""
122
91
  if not websession:
@@ -148,6 +117,7 @@ class SmileComm:
148
117
  # Command accepted gives empty body with status 202
149
118
  if resp.status == 202:
150
119
  return
120
+
151
121
  # Cornercase for stretch not responding with 202
152
122
  if method == "put" and resp.status == 200:
153
123
  return
@@ -164,9 +134,9 @@ class SmileComm:
164
134
  try:
165
135
  # Encode to ensure utf8 parsing
166
136
  xml = etree.XML(escape_illegal_xml_characters(result).encode())
167
- except etree.ParseError:
137
+ except etree.ParseError as exc:
168
138
  LOGGER.warning("Smile returns invalid XML for %s", self._endpoint)
169
- raise InvalidXMLError
139
+ raise InvalidXMLError from exc
170
140
 
171
141
  return xml
172
142
 
@@ -202,15 +172,15 @@ class SmileComm:
202
172
  )
203
173
  except (
204
174
  ClientError
205
- ) as err: # ClientError is an ancestor class of ServerTimeoutError
175
+ ) as exc: # ClientError is an ancestor class of ServerTimeoutError
206
176
  if retry < 1:
207
177
  LOGGER.warning(
208
178
  "Failed sending %s %s to Plugwise Smile, error: %s",
209
179
  method,
210
180
  command,
211
- err,
181
+ exc,
212
182
  )
213
- raise ConnectionFailedError
183
+ raise ConnectionFailedError from exc
214
184
  return await self._request(command, retry - 1)
215
185
 
216
186
  return await self._request_validate(resp, method)
@@ -225,33 +195,29 @@ class SmileHelper:
225
195
 
226
196
  def __init__(self) -> None:
227
197
  """Set the constructor for this class."""
228
- self._appliances: etree
229
198
  self._cooling_activation_outdoor_temp: float
230
199
  self._cooling_deactivation_threshold: float
231
- self._cooling_present = False
200
+ self._cooling_present: bool
232
201
  self._count: int
233
202
  self._dhw_allowed_modes: list[str] = []
234
203
  self._domain_objects: etree
235
- self._elga = False
204
+ self._endpoint: str
205
+ self._elga: bool
236
206
  self._gw_allowed_modes: list[str] = []
237
207
  self._heater_id: str
238
208
  self._home_location: str
239
- self._is_thermostat = False
240
- self._last_active: dict[str, str | None] = {}
209
+ self._is_thermostat: bool
210
+ self._last_active: dict[str, str | None]
241
211
  self._last_modified: dict[str, str] = {}
242
- self._locations: etree
243
- self._loc_data: dict[str, ThermoLoc] = {}
244
- self._modules: etree
245
212
  self._notifications: dict[str, dict[str, str]] = {}
246
- self._on_off_device = False
247
- self._opentherm_device = False
213
+ self._on_off_device: bool
214
+ self._opentherm_device: bool
248
215
  self._outdoor_temp: float
249
216
  self._reg_allowed_modes: list[str] = []
250
217
  self._schedule_old_states: dict[str, dict[str, str]] = {}
251
218
  self._smile_legacy = False
252
219
  self._status: etree
253
- self._stretch_v2 = False
254
- self._stretch_v3 = False
220
+ self._stretch_v2: bool
255
221
  self._system: etree
256
222
  self._thermo_locs: dict[str, ThermoLoc] = {}
257
223
  ###################################################################
@@ -270,17 +236,16 @@ class SmileHelper:
270
236
  self._cooling_enabled = False
271
237
 
272
238
  self.device_items: int = 0
273
- self.device_list: list[str]
274
239
  self.gateway_id: str
275
240
  self.gw_data: GatewayData = {}
276
241
  self.gw_devices: dict[str, DeviceData] = {}
242
+ self.loc_data: dict[str, ThermoLoc]
277
243
  self.smile_fw_version: str | None = None
278
244
  self.smile_hw_version: str | None = None
279
245
  self.smile_mac_address: str | None = None
280
246
  self.smile_model: str
281
247
  self.smile_name: str
282
248
  self.smile_type: str
283
- self.smile_version: tuple[str, semver.version.Version]
284
249
  self.smile_zigbee_mac_address: str | None = None
285
250
  self.therms_with_offset_func: list[str] = []
286
251
 
@@ -291,34 +256,14 @@ class SmileHelper:
291
256
  def _all_locations(self) -> None:
292
257
  """Collect all locations."""
293
258
  loc = Munch()
294
-
295
- locations = self._locations.findall("./location")
296
- # Legacy Anna without outdoor_temp and Stretches have no locations, create fake location-data
297
- if not locations and self._smile_legacy:
298
- self._home_location = FAKE_LOC
299
- self._loc_data[FAKE_LOC] = {"name": "Home"}
300
- return
301
-
259
+ locations = self._domain_objects.findall("./location")
302
260
  for location in locations:
303
261
  loc.name = location.find("name").text
304
262
  loc.loc_id = location.attrib["id"]
305
- # Filter the valid single location for P1 legacy: services not empty
306
- locator = "./services"
307
- if (
308
- self._smile_legacy
309
- and self.smile_type == "power"
310
- and len(location.find(locator)) == 0
311
- ):
312
- continue
313
-
314
263
  if loc.name == "Home":
315
264
  self._home_location = loc.loc_id
316
- # Replace location-name for P1 legacy, can contain privacy-related info
317
- if self._smile_legacy and self.smile_type == "power":
318
- loc.name = "Home"
319
- self._home_location = loc.loc_id
320
265
 
321
- self._loc_data[loc.loc_id] = {"name": loc.name}
266
+ self.loc_data[loc.loc_id] = {"name": loc.name}
322
267
 
323
268
  def _get_module_data(
324
269
  self, appliance: etree, locator: str, mod_type: str
@@ -338,9 +283,9 @@ class SmileHelper:
338
283
  }
339
284
  if (appl_search := appliance.find(locator)) is not None:
340
285
  link_id = appl_search.attrib["id"]
341
- loc = f".//{mod_type}[@id='{link_id}']...."
286
+ loc = f".//services/{mod_type}[@id='{link_id}']...."
342
287
  # Not possible to walrus for some reason...
343
- module = self._modules.find(loc)
288
+ module = self._domain_objects.find(loc)
344
289
  if module is not None: # pylint: disable=consider-using-assignment-expr
345
290
  model_data["contents"] = True
346
291
  if (vendor_name := module.find("vendor_name").text) is not None:
@@ -354,12 +299,6 @@ class SmileHelper:
354
299
  if (zb_node := module.find("./protocols/zig_bee_node")) is not None:
355
300
  model_data["zigbee_mac_address"] = zb_node.find("mac_address").text
356
301
  model_data["reachable"] = zb_node.find("reachable").text == "true"
357
- # Stretches
358
- if (router := module.find("./protocols/network_router")) is not None:
359
- model_data["zigbee_mac_address"] = router.find("mac_address").text
360
- # Also look for the Circle+/Stealth M+
361
- if (coord := module.find("./protocols/network_coordinator")) is not None:
362
- model_data["zigbee_mac_address"] = coord.find("mac_address").text
363
302
 
364
303
  return model_data
365
304
 
@@ -368,24 +307,14 @@ class SmileHelper:
368
307
 
369
308
  Collect energy device info (Circle, Plug, Stealth): firmware, model and vendor name.
370
309
  """
371
- if self.smile_type in ("power", "stretch"):
372
- locator = "./services/electricity_point_meter"
373
- if not self._smile_legacy:
374
- locator = "./logs/point_log/electricity_point_meter"
310
+ if self.smile_type == "power":
311
+ locator = "./logs/point_log/electricity_point_meter"
375
312
  mod_type = "electricity_point_meter"
376
-
377
313
  module_data = self._get_module_data(appliance, locator, mod_type)
378
- # Filter appliance without zigbee_mac, it's an orphaned device
379
314
  appl.zigbee_mac = module_data["zigbee_mac_address"]
380
- if appl.zigbee_mac is None and self.smile_type != "power":
381
- return None
382
-
383
315
  appl.hardware = module_data["hardware_version"]
384
316
  appl.model = module_data["vendor_model"]
385
317
  appl.vendor_name = module_data["vendor_name"]
386
- if appl.hardware is not None:
387
- hw_version = appl.hardware.replace("-", "")
388
- appl.model = version_to_model(hw_version)
389
318
  appl.firmware = module_data["firmware_version"]
390
319
 
391
320
  return appl
@@ -422,7 +351,7 @@ class SmileHelper:
422
351
 
423
352
  # Adam: look for the ZigBee MAC address of the Smile
424
353
  if self.smile(ADAM) and (
425
- (found := self._modules.find(".//protocols/zig_bee_coordinator")) is not None
354
+ (found := self._domain_objects.find(".//protocols/zig_bee_coordinator")) is not None
426
355
  ):
427
356
  appl.zigbee_mac = found.find("mac_address").text
428
357
 
@@ -463,7 +392,7 @@ class SmileHelper:
463
392
  if appl.pwclass == "heater_central":
464
393
  # Remove heater_central when no active device present
465
394
  if not self._opentherm_device and not self._on_off_device:
466
- return None
395
+ return None # pragma: no cover
467
396
 
468
397
  # Find the valid heater_central
469
398
  self._heater_id = self._check_heater_central()
@@ -504,7 +433,7 @@ class SmileHelper:
504
433
 
505
434
  return appl
506
435
 
507
- # Collect info from Stretches
436
+ # Collect info from power-related devices (Plug, Aqara Smart Plug)
508
437
  appl = self._energy_device_info_finder(appliance, appl)
509
438
 
510
439
  return appl
@@ -518,7 +447,7 @@ class SmileHelper:
518
447
  locator = "./appliance[type='heater_central']"
519
448
  hc_count = 0
520
449
  hc_list: list[dict[str, bool]] = []
521
- for heater_central in self._appliances.findall(locator):
450
+ for heater_central in self._domain_objects.findall(locator):
522
451
  hc_count += 1
523
452
  hc_id: str = heater_central.attrib["id"]
524
453
  has_actuators: bool = (
@@ -539,17 +468,15 @@ class SmileHelper:
539
468
 
540
469
  def _p1_smartmeter_info_finder(self, appl: Munch) -> None:
541
470
  """Collect P1 DSMR Smartmeter info."""
542
- loc_id = next(iter(self._loc_data.keys()))
471
+ loc_id = next(iter(self.loc_data.keys()))
543
472
  appl.dev_id = self.gateway_id
544
473
  appl.location = loc_id
545
- if self._smile_legacy:
546
- appl.dev_id = loc_id
547
474
  appl.mac = None
548
475
  appl.model = self.smile_model
549
476
  appl.name = "P1"
550
477
  appl.pwclass = "smartmeter"
551
478
  appl.zigbee_mac = None
552
- location = self._locations.find(f'./location[@id="{loc_id}"]')
479
+ location = self._domain_objects.find(f'./location[@id="{loc_id}"]')
553
480
  appl = self._energy_device_info_finder(location, appl)
554
481
 
555
482
  self.gw_devices[appl.dev_id] = {"dev_class": appl.pwclass}
@@ -570,46 +497,12 @@ class SmileHelper:
570
497
  self.gw_devices[appl.dev_id][p1_key] = value
571
498
  self._count += 1
572
499
 
573
- def _create_legacy_gateway(self) -> None:
574
- """Create the (missing) gateway devices for legacy Anna, P1 and Stretch.
575
-
576
- Use the home_location or FAKE_APPL as device id.
577
- """
578
- self.gateway_id = self._home_location
579
- if self.smile_type == "power":
580
- self.gateway_id = FAKE_APPL
581
-
582
- self.gw_devices[self.gateway_id] = {"dev_class": "gateway"}
583
- self._count += 1
584
- for key, value in {
585
- "firmware": self.smile_fw_version,
586
- "location": self._home_location,
587
- "mac_address": self.smile_mac_address,
588
- "model": self.smile_model,
589
- "name": self.smile_name,
590
- "zigbee_mac_address": self.smile_zigbee_mac_address,
591
- "vendor": "Plugwise",
592
- }.items():
593
- if value is not None:
594
- gw_key = cast(ApplianceType, key)
595
- self.gw_devices[self.gateway_id][gw_key] = value
596
- self._count += 1
597
-
598
500
  def _all_appliances(self) -> None:
599
501
  """Collect all appliances with relevant info."""
600
502
  self._count = 0
601
503
  self._all_locations()
602
504
 
603
- if self._smile_legacy:
604
- self._create_legacy_gateway()
605
- # For legacy P1 collect the connected SmartMeter info
606
- if self.smile_type == "power":
607
- appl = Munch()
608
- self._p1_smartmeter_info_finder(appl)
609
- # Legacy P1 has no more devices
610
- return
611
-
612
- for appliance in self._appliances.findall("./appliance"):
505
+ for appliance in self._domain_objects.findall("./appliance"):
613
506
  appl = Munch()
614
507
  appl.pwclass = appliance.find("type").text
615
508
  # Skip thermostats that have this key, should be an orphaned device (Core #81712)
@@ -622,11 +515,9 @@ class SmileHelper:
622
515
  appl.location = None
623
516
  if (appl_loc := appliance.find("location")) is not None:
624
517
  appl.location = appl_loc.attrib["id"]
625
- # Provide a location for legacy_anna, also don't assign the _home_location
626
- # to thermostat-devices without a location, they are not active
627
- elif (
628
- self._smile_legacy and self.smile_type == "thermostat"
629
- ) or appl.pwclass not in THERMOSTAT_CLASSES:
518
+ # Don't assign the _home_location to thermostat-devices without a location,
519
+ # they are not active
520
+ elif appl.pwclass not in THERMOSTAT_CLASSES:
630
521
  appl.location = self._home_location
631
522
 
632
523
  appl.dev_id = appliance.attrib["id"]
@@ -639,7 +530,7 @@ class SmileHelper:
639
530
  appl.vendor_name = None
640
531
 
641
532
  # Determine class for this appliance
642
- # Skip on heater_central when no active device present or on orphaned stretch devices
533
+ # Skip on heater_central when no active device present
643
534
  if not (appl := self._appliance_info_finder(appliance, appl)):
644
535
  continue
645
536
 
@@ -652,12 +543,8 @@ class SmileHelper:
652
543
  if appl.pwclass == "gateway" and self.smile_type == "power":
653
544
  appl.dev_id = appl.location
654
545
 
655
- # Don't show orphaned non-legacy thermostat-types or the OpenTherm Gateway.
656
- if (
657
- not self._smile_legacy
658
- and appl.pwclass in THERMOSTAT_CLASSES
659
- and appl.location is None
660
- ):
546
+ # Don't show orphaned thermostat-types or the OpenTherm Gateway.
547
+ if appl.pwclass in THERMOSTAT_CLASSES and appl.location is None:
661
548
  continue
662
549
 
663
550
  self.gw_devices[appl.dev_id] = {"dev_class": appl.pwclass}
@@ -677,7 +564,7 @@ class SmileHelper:
677
564
  self.gw_devices[appl.dev_id][appl_key] = value
678
565
  self._count += 1
679
566
 
680
- # For non-legacy P1 collect the connected SmartMeter info
567
+ # For P1 collect the connected SmartMeter info
681
568
  if self.smile_type == "power":
682
569
  self._p1_smartmeter_info_finder(appl)
683
570
  # P1: for gateway and smartmeter switch device_id - part 2
@@ -697,22 +584,6 @@ class SmileHelper:
697
584
  add_to_front = {dev_id: tmp_device}
698
585
  self.gw_devices = {**add_to_front, **cleared_dict}
699
586
 
700
- def _match_locations(self) -> dict[str, ThermoLoc]:
701
- """Helper-function for _scan_thermostats().
702
-
703
- Match appliances with locations.
704
- """
705
- matched_locations: dict[str, ThermoLoc] = {}
706
- for location_id, location_details in self._loc_data.items():
707
- for appliance_details in self.gw_devices.values():
708
- if appliance_details["location"] == location_id:
709
- location_details.update(
710
- {"master": None, "master_prio": 0, "slaves": set()}
711
- )
712
- matched_locations[location_id] = location_details
713
-
714
- return matched_locations
715
-
716
587
  def _control_state(self, loc_id: str) -> str | bool:
717
588
  """Helper-function for _device_data_adam().
718
589
 
@@ -728,30 +599,12 @@ class SmileHelper:
728
599
 
729
600
  return False
730
601
 
731
- def _presets_legacy(self) -> dict[str, list[float]]:
732
- """Helper-function for presets() - collect Presets for a legacy Anna."""
733
- presets: dict[str, list[float]] = {}
734
- for directive in self._domain_objects.findall("rule/directives/when/then"):
735
- if directive is not None and directive.get("icon") is not None:
736
- # Ensure list of heating_setpoint, cooling_setpoint
737
- presets[directive.attrib["icon"]] = [
738
- float(directive.attrib["temperature"]),
739
- 0,
740
- ]
741
-
742
- return presets
743
-
744
602
  def _presets(self, loc_id: str) -> dict[str, list[float]]:
745
603
  """Collect Presets for a Thermostat based on location_id."""
746
604
  presets: dict[str, list[float]] = {}
747
605
  tag_1 = "zone_setpoint_and_state_based_on_preset"
748
606
  tag_2 = "Thermostat presets"
749
-
750
- if self._smile_legacy:
751
- return self._presets_legacy()
752
-
753
607
  if not (rule_ids := self._rule_ids_by_tag(tag_1, loc_id)):
754
- rule_ids = None
755
608
  if not (rule_ids := self._rule_ids_by_name(tag_2, loc_id)):
756
609
  return presets # pragma: no cover
757
610
 
@@ -813,9 +666,6 @@ class SmileHelper:
813
666
  for measurement, attrs in measurements.items():
814
667
  p_locator = f'.//logs/point_log[type="{measurement}"]/period/measurement'
815
668
  if (appl_p_loc := appliance.find(p_locator)) is not None:
816
- if self._smile_legacy and measurement == "domestic_hot_water_state":
817
- continue
818
-
819
669
  # Skip known obsolete measurements
820
670
  updated_date_locator = (
821
671
  f'.//logs/point_log[type="{measurement}"]/updated_date'
@@ -909,7 +759,7 @@ class SmileHelper:
909
759
  def _get_appliances_with_offset_functionality(self) -> list[str]:
910
760
  """Helper-function collecting all appliance that have offset_functionality."""
911
761
  therm_list: list[str] = []
912
- offset_appls = self._appliances.findall(
762
+ offset_appls = self._domain_objects.findall(
913
763
  './/actuator_functionalities/offset_functionality[type="temperature_offset"]/offset/../../..'
914
764
  )
915
765
  for item in offset_appls:
@@ -933,10 +783,6 @@ class SmileHelper:
933
783
  functionality = "thermostat_functionality"
934
784
  if item == "temperature_offset":
935
785
  functionality = "offset_functionality"
936
- # Don't support temperature_offset for legacy Anna
937
- if self._smile_legacy:
938
- continue
939
-
940
786
  # When there is no updated_date-text, skip the actuator
941
787
  updated_date_location = f'.//actuator_functionalities/{functionality}[type="{item}"]/updated_date'
942
788
  if (
@@ -1052,19 +898,16 @@ class SmileHelper:
1052
898
  Collect the appliance-data based on device id.
1053
899
  """
1054
900
  data: DeviceData = {"binary_sensors": {}, "sensors": {}, "switches": {}}
1055
- # Get P1 smartmeter data from LOCATIONS or MODULES
901
+ # Get P1 smartmeter data from LOCATIONS
1056
902
  device = self.gw_devices[dev_id]
1057
903
  # !! DON'T CHANGE below two if-lines, will break stuff !!
1058
904
  if self.smile_type == "power":
1059
905
  if device["dev_class"] == "smartmeter":
1060
- if not self._smile_legacy:
1061
- data.update(self._power_data_from_location(device["location"]))
1062
- else:
1063
- data.update(self._power_data_from_modules())
906
+ data.update(self._power_data_from_location(device["location"]))
1064
907
 
1065
908
  return data
1066
909
 
1067
- # Get non-p1 data from APPLIANCES, for legacy from DOMAIN_OBJECTS.
910
+ # Get non-P1 data from APPLIANCES
1068
911
  measurements = DEVICE_MEASUREMENTS
1069
912
  if self._is_thermostat and dev_id == self._heater_id:
1070
913
  measurements = HEATER_CENTRAL_MEASUREMENTS
@@ -1074,7 +917,7 @@ class SmileHelper:
1074
917
  # Counting of this item is done in _appliance_measurements()
1075
918
 
1076
919
  if (
1077
- appliance := self._appliances.find(f'./appliance[@id="{dev_id}"]')
920
+ appliance := self._domain_objects.find(f'./appliance[@id="{dev_id}"]')
1078
921
  ) is not None:
1079
922
  self._appliance_measurements(appliance, data, measurements)
1080
923
  self._get_lock_state(appliance, data)
@@ -1141,33 +984,6 @@ class SmileHelper:
1141
984
 
1142
985
  return data
1143
986
 
1144
- def _rank_thermostat(
1145
- self,
1146
- thermo_matching: dict[str, int],
1147
- loc_id: str,
1148
- appliance_id: str,
1149
- appliance_details: DeviceData,
1150
- ) -> None:
1151
- """Helper-function for _scan_thermostats().
1152
-
1153
- Rank the thermostat based on appliance_details: master or slave.
1154
- """
1155
- appl_class = appliance_details["dev_class"]
1156
- appl_d_loc = appliance_details["location"]
1157
- if loc_id == appl_d_loc and appl_class in thermo_matching:
1158
- # Pre-elect new master
1159
- if thermo_matching[appl_class] > self._thermo_locs[loc_id]["master_prio"]:
1160
- # Demote former master
1161
- if (tl_master := self._thermo_locs[loc_id]["master"]) is not None:
1162
- self._thermo_locs[loc_id]["slaves"].add(tl_master)
1163
-
1164
- # Crown master
1165
- self._thermo_locs[loc_id]["master_prio"] = thermo_matching[appl_class]
1166
- self._thermo_locs[loc_id]["master"] = appliance_id
1167
-
1168
- else:
1169
- self._thermo_locs[loc_id]["slaves"].add(appliance_id)
1170
-
1171
987
  def _scan_thermostats(self) -> None:
1172
988
  """Helper-function for smile.py: get_all_devices().
1173
989
 
@@ -1194,26 +1010,56 @@ class SmileHelper:
1194
1010
  if "slaves" in tl_loc_id and dev_id in tl_loc_id["slaves"]:
1195
1011
  device["dev_class"] = "thermo_sensor"
1196
1012
 
1197
- def _thermostat_uri_legacy(self) -> str:
1198
- """Helper-function for _thermostat_uri().
1013
+ def _match_locations(self) -> dict[str, ThermoLoc]:
1014
+ """Helper-function for _scan_thermostats().
1199
1015
 
1200
- Determine the location-set_temperature uri - from APPLIANCES.
1016
+ Match appliances with locations.
1201
1017
  """
1202
- locator = "./appliance[type='thermostat']"
1203
- appliance_id = self._appliances.find(locator).attrib["id"]
1018
+ matched_locations: dict[str, ThermoLoc] = {}
1019
+ for location_id, location_details in self.loc_data.items():
1020
+ for appliance_details in self.gw_devices.values():
1021
+ if appliance_details["location"] == location_id:
1022
+ location_details.update(
1023
+ {"master": None, "master_prio": 0, "slaves": set()}
1024
+ )
1025
+ matched_locations[location_id] = location_details
1026
+
1027
+ return matched_locations
1204
1028
 
1205
- return f"{APPLIANCES};id={appliance_id}/thermostat"
1029
+ def _rank_thermostat(
1030
+ self,
1031
+ thermo_matching: dict[str, int],
1032
+ loc_id: str,
1033
+ appliance_id: str,
1034
+ appliance_details: DeviceData,
1035
+ ) -> None:
1036
+ """Helper-function for _scan_thermostats().
1037
+
1038
+ Rank the thermostat based on appliance_details: master or slave.
1039
+ """
1040
+ appl_class = appliance_details["dev_class"]
1041
+ appl_d_loc = appliance_details["location"]
1042
+ if loc_id == appl_d_loc and appl_class in thermo_matching:
1043
+ # Pre-elect new master
1044
+ if thermo_matching[appl_class] > self._thermo_locs[loc_id]["master_prio"]:
1045
+ # Demote former master
1046
+ if (tl_master := self._thermo_locs[loc_id]["master"]) is not None:
1047
+ self._thermo_locs[loc_id]["slaves"].add(tl_master)
1048
+
1049
+ # Crown master
1050
+ self._thermo_locs[loc_id]["master_prio"] = thermo_matching[appl_class]
1051
+ self._thermo_locs[loc_id]["master"] = appliance_id
1052
+
1053
+ else:
1054
+ self._thermo_locs[loc_id]["slaves"].add(appliance_id)
1206
1055
 
1207
1056
  def _thermostat_uri(self, loc_id: str) -> str:
1208
1057
  """Helper-function for smile.py: set_temperature().
1209
1058
 
1210
1059
  Determine the location-set_temperature uri - from LOCATIONS.
1211
1060
  """
1212
- if self._smile_legacy:
1213
- return self._thermostat_uri_legacy()
1214
-
1215
1061
  locator = f'./location[@id="{loc_id}"]/actuator_functionalities/thermostat_functionality'
1216
- thermostat_functionality_id = self._locations.find(locator).attrib["id"]
1062
+ thermostat_functionality_id = self._domain_objects.find(locator).attrib["id"]
1217
1063
 
1218
1064
  return f"{LOCATIONS};id={loc_id}/thermostat;id={thermostat_functionality_id}"
1219
1065
 
@@ -1233,8 +1079,8 @@ class SmileHelper:
1233
1079
  group_name = group.find("name").text
1234
1080
  group_type = group.find("type").text
1235
1081
  group_appliances = group.findall("appliances/appliance")
1082
+ # Check if members are not orphaned
1236
1083
  for item in group_appliances:
1237
- # Check if members are not orphaned - stretch
1238
1084
  if item.attrib["id"] in self.gw_devices:
1239
1085
  members.append(item.attrib["id"])
1240
1086
 
@@ -1261,7 +1107,7 @@ class SmileHelper:
1261
1107
  """
1262
1108
  loc_found: int = 0
1263
1109
  open_valve_count: int = 0
1264
- for appliance in self._appliances.findall("./appliance"):
1110
+ for appliance in self._domain_objects.findall("./appliance"):
1265
1111
  locator = './logs/point_log[type="valve_position"]/period/measurement'
1266
1112
  if (appl_loc := appliance.find(locator)) is not None:
1267
1113
  loc_found += 1
@@ -1305,7 +1151,6 @@ class SmileHelper:
1305
1151
  """Helper-function for _power_data_from_location() and _power_data_from_modules()."""
1306
1152
  loc.found = True
1307
1153
  # If locator not found look for P1 gas_consumed or phase data (without tariff)
1308
- # or for P1 legacy electricity_point_meter or gas_*_meter data
1309
1154
  if loc.logs.find(loc.locator) is None:
1310
1155
  if "log" in loc.log_type and (
1311
1156
  "gas" in loc.measurement or "phase" in loc.measurement
@@ -1321,25 +1166,9 @@ class SmileHelper:
1321
1166
  if loc.logs.find(loc.locator) is None:
1322
1167
  loc.found = False
1323
1168
  return loc
1324
- # P1 legacy point_meter has no tariff_indicator
1325
- elif "meter" in loc.log_type and (
1326
- "point" in loc.log_type or "gas" in loc.measurement
1327
- ):
1328
- # Avoid double processing by skipping one peak-list option
1329
- if loc.peak_select == "nl_offpeak":
1330
- loc.found = False
1331
- return loc
1332
-
1333
- loc.locator = (
1334
- f"./{loc.meas_list[0]}_{loc.log_type}/"
1335
- f'measurement[@directionality="{loc.meas_list[1]}"]'
1336
- )
1337
- if loc.logs.find(loc.locator) is None:
1338
- loc.found = False
1339
- return loc
1340
1169
  else:
1341
- loc.found = False
1342
- return loc
1170
+ loc.found = False # pragma: no cover
1171
+ return loc # pragma: no cover
1343
1172
 
1344
1173
  if (peak := loc.peak_select.split("_")[1]) == "offpeak":
1345
1174
  peak = "off_peak"
@@ -1366,7 +1195,7 @@ class SmileHelper:
1366
1195
  peak_list: list[str] = ["nl_peak", "nl_offpeak"]
1367
1196
  t_string = "tariff"
1368
1197
 
1369
- search = self._locations
1198
+ search = self._domain_objects
1370
1199
  loc.logs = search.find(f'./location[@id="{loc_id}"]/logs')
1371
1200
  for loc.measurement, loc.attrs in P1_MEASUREMENTS.items():
1372
1201
  for loc.log_type in log_list:
@@ -1388,92 +1217,16 @@ class SmileHelper:
1388
1217
  self._count += len(direct_data["sensors"])
1389
1218
  return direct_data
1390
1219
 
1391
- def _power_data_from_modules(self) -> DeviceData:
1392
- """Helper-function for smile.py: _get_device_data().
1393
-
1394
- Collect the power-data from MODULES (P1 legacy only).
1395
- """
1396
- direct_data: DeviceData = {"sensors": {}}
1397
- loc = Munch()
1398
- mod_list: list[str] = ["interval_meter", "cumulative_meter", "point_meter"]
1399
- peak_list: list[str] = ["nl_peak", "nl_offpeak"]
1400
- t_string = "tariff_indicator"
1401
-
1402
- search = self._modules
1403
- mod_logs = search.findall("./module/services")
1404
- for loc.measurement, loc.attrs in P1_LEGACY_MEASUREMENTS.items():
1405
- loc.meas_list = loc.measurement.split("_")
1406
- for loc.logs in mod_logs:
1407
- for loc.log_type in mod_list:
1408
- for loc.peak_select in peak_list:
1409
- loc.locator = (
1410
- f"./{loc.meas_list[0]}_{loc.log_type}/measurement"
1411
- f'[@directionality="{loc.meas_list[1]}"][@{t_string}="{loc.peak_select}"]'
1412
- )
1413
- loc = self._power_data_peak_value(loc)
1414
- if not loc.found:
1415
- continue
1416
-
1417
- direct_data = self.power_data_energy_diff(
1418
- loc.measurement, loc.net_string, loc.f_val, direct_data
1419
- )
1420
- key = cast(SensorType, loc.key_string)
1421
- direct_data["sensors"][key] = loc.f_val
1422
-
1423
- self._count += len(direct_data["sensors"])
1424
- return direct_data
1425
-
1426
1220
  def _preset(self, loc_id: str) -> str | None:
1427
1221
  """Helper-function for smile.py: device_data_climate().
1428
1222
 
1429
1223
  Collect the active preset based on Location ID.
1430
1224
  """
1431
- if not self._smile_legacy:
1432
- locator = f'./location[@id="{loc_id}"]/preset'
1433
- if (preset := self._domain_objects.find(locator)) is not None:
1434
- return str(preset.text)
1435
- return None
1225
+ locator = f'./location[@id="{loc_id}"]/preset'
1226
+ if (preset := self._domain_objects.find(locator)) is not None:
1227
+ return str(preset.text)
1436
1228
 
1437
- locator = "./rule[active='true']/directives/when/then"
1438
- if (
1439
- not (active_rule := etree_to_dict(self._domain_objects.find(locator)))
1440
- or "icon" not in active_rule
1441
- ):
1442
- return None
1443
-
1444
- return active_rule["icon"]
1445
-
1446
- def _schedules_legacy(
1447
- self,
1448
- avail: list[str],
1449
- location: str,
1450
- sel: str,
1451
- ) -> tuple[list[str], str]:
1452
- """Helper-function for _schedules().
1453
-
1454
- Collect available schedules/schedules for the legacy thermostat.
1455
- """
1456
- name: str | None = None
1457
-
1458
- search = self._domain_objects
1459
- for schedule in search.findall("./rule"):
1460
- if rule_name := schedule.find("name").text:
1461
- if "preset" not in rule_name:
1462
- name = rule_name
1463
-
1464
- log_type = "schedule_state"
1465
- locator = f"./appliance[type='thermostat']/logs/point_log[type='{log_type}']/period/measurement"
1466
- active = False
1467
- if (result := search.find(locator)) is not None:
1468
- active = result.text == "on"
1469
-
1470
- if name is not None:
1471
- avail = [name]
1472
- if active:
1473
- sel = name
1474
-
1475
- self._last_active[location] = "".join(map(str, avail))
1476
- return avail, sel
1229
+ return None # pragma: no cover
1477
1230
 
1478
1231
  def _schedules(self, location: str) -> tuple[list[str], str]:
1479
1232
  """Helper-function for smile.py: _device_data_climate().
@@ -1484,11 +1237,6 @@ class SmileHelper:
1484
1237
  available: list[str] = [NONE]
1485
1238
  rule_ids: dict[str, dict[str, str]] = {}
1486
1239
  selected = NONE
1487
-
1488
- # Legacy Anna schedule, only one schedule allowed
1489
- if self._smile_legacy:
1490
- return self._schedules_legacy(available, location, selected)
1491
-
1492
1240
  # Adam schedules, one schedule can be linked to various locations
1493
1241
  # self._last_active contains the locations and the active schedule name per location, or None
1494
1242
  if location not in self._last_active:
@@ -1547,7 +1295,6 @@ class SmileHelper:
1547
1295
  locator = f'./location[@id="{obj_id}"]/logs/point_log[type="{measurement}"]/period/measurement'
1548
1296
  if (found := search.find(locator)) is not None:
1549
1297
  val = format_measure(found.text, NONE)
1550
- return val
1551
1298
 
1552
1299
  return val
1553
1300
 
@@ -1558,9 +1305,6 @@ class SmileHelper:
1558
1305
  """
1559
1306
  actuator = "actuator_functionalities"
1560
1307
  func_type = "relay_functionality"
1561
- if self._stretch_v2:
1562
- actuator = "actuators"
1563
- func_type = "relay"
1564
1308
  if xml.find("type").text not in SPECIAL_PLUG_TYPES:
1565
1309
  locator = f"./{actuator}/{func_type}/lock"
1566
1310
  if (found := xml.find(locator)) is not None:
@@ -1579,8 +1323,19 @@ class SmileHelper:
1579
1323
  if (state := xml.find(locator)) is not None:
1580
1324
  data["switches"][name] = state.text == "on"
1581
1325
  self._count += 1
1582
- # Remove the cooling_enabled binary_sensor when the corresponding switch is present
1583
- # Except for Elga
1584
- if toggle == "cooling_enabled" and not self._elga:
1585
- data["binary_sensors"].pop("cooling_enabled")
1586
- self._count -= 1
1326
+
1327
+ def _get_plugwise_notifications(self) -> None:
1328
+ """Collect the Plugwise notifications."""
1329
+ self._notifications = {}
1330
+ for notification in self._domain_objects.findall("./notification"):
1331
+ try:
1332
+ msg_id = notification.attrib["id"]
1333
+ msg_type = notification.find("type").text
1334
+ msg = notification.find("message").text
1335
+ self._notifications.update({msg_id: {msg_type: msg}})
1336
+ LOGGER.debug("Plugwise notifications: %s", self._notifications)
1337
+ except AttributeError: # pragma: no cover
1338
+ LOGGER.debug(
1339
+ "Plugwise notification present but unable to process, manually investigate: %s",
1340
+ f"{self._endpoint}{DOMAIN_OBJECTS}",
1341
+ )