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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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,28 +599,11 @@ 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
608
  if not (rule_ids := self._rule_ids_by_name(tag_2, loc_id)):
755
609
  return presets # pragma: no cover
@@ -767,35 +621,38 @@ class SmileHelper:
767
621
 
768
622
  return presets
769
623
 
770
- def _rule_ids_by_name(self, name: str, loc_id: str) -> dict[str, str]:
624
+ def _rule_ids_by_name(self, name: str, loc_id: str) -> dict[str, dict[str, str]]:
771
625
  """Helper-function for _presets().
772
626
 
773
627
  Obtain the rule_id from the given name and and provide the location_id, when present.
774
628
  """
775
- schedule_ids: dict[str, str] = {}
629
+ schedule_ids: dict[str, dict[str, str]] = {}
776
630
  locator = f'./contexts/context/zone/location[@id="{loc_id}"]'
777
631
  for rule in self._domain_objects.findall(f'./rule[name="{name}"]'):
632
+ active = rule.find("active").text
778
633
  if rule.find(locator) is not None:
779
- schedule_ids[rule.attrib["id"]] = loc_id
634
+ schedule_ids[rule.attrib["id"]] = {"location": loc_id, "name": name, "active": active}
780
635
  else:
781
- schedule_ids[rule.attrib["id"]] = NONE
636
+ schedule_ids[rule.attrib["id"]] = {"location": NONE, "name": name, "active": active}
782
637
 
783
638
  return schedule_ids
784
639
 
785
- def _rule_ids_by_tag(self, tag: str, loc_id: str) -> dict[str, str]:
640
+ def _rule_ids_by_tag(self, tag: str, loc_id: str) -> dict[str, dict[str, str]]:
786
641
  """Helper-function for _presets(), _schedules() and _last_active_schedule().
787
642
 
788
643
  Obtain the rule_id from the given template_tag and provide the location_id, when present.
789
644
  """
790
- schedule_ids: dict[str, str] = {}
645
+ schedule_ids: dict[str, dict[str, str]] = {}
791
646
  locator1 = f'./template[@tag="{tag}"]'
792
647
  locator2 = f'./contexts/context/zone/location[@id="{loc_id}"]'
793
648
  for rule in self._domain_objects.findall("./rule"):
794
649
  if rule.find(locator1) is not None:
650
+ name = rule.find("name").text
651
+ active = rule.find("active").text
795
652
  if rule.find(locator2) is not None:
796
- schedule_ids[rule.attrib["id"]] = loc_id
653
+ schedule_ids[rule.attrib["id"]] = {"location": loc_id, "name": name, "active": active}
797
654
  else:
798
- schedule_ids[rule.attrib["id"]] = NONE
655
+ schedule_ids[rule.attrib["id"]] = {"location": NONE, "name": name, "active": active}
799
656
 
800
657
  return schedule_ids
801
658
 
@@ -809,9 +666,6 @@ class SmileHelper:
809
666
  for measurement, attrs in measurements.items():
810
667
  p_locator = f'.//logs/point_log[type="{measurement}"]/period/measurement'
811
668
  if (appl_p_loc := appliance.find(p_locator)) is not None:
812
- if self._smile_legacy and measurement == "domestic_hot_water_state":
813
- continue
814
-
815
669
  # Skip known obsolete measurements
816
670
  updated_date_locator = (
817
671
  f'.//logs/point_log[type="{measurement}"]/updated_date'
@@ -882,10 +736,10 @@ class SmileHelper:
882
736
  # Don't count the above top-level dicts, only the remaining single items
883
737
  self._count += len(data) - 3
884
738
 
885
- def _wireless_availablity(self, appliance: etree, data: DeviceData) -> None:
739
+ def _wireless_availability(self, appliance: etree, data: DeviceData) -> None:
886
740
  """Helper-function for _get_measurement_data().
887
741
 
888
- Collect the availablity-status for wireless connected devices.
742
+ Collect the availability-status for wireless connected devices.
889
743
  """
890
744
  if self.smile(ADAM):
891
745
  # Collect for Plugs
@@ -905,7 +759,7 @@ class SmileHelper:
905
759
  def _get_appliances_with_offset_functionality(self) -> list[str]:
906
760
  """Helper-function collecting all appliance that have offset_functionality."""
907
761
  therm_list: list[str] = []
908
- offset_appls = self._appliances.findall(
762
+ offset_appls = self._domain_objects.findall(
909
763
  './/actuator_functionalities/offset_functionality[type="temperature_offset"]/offset/../../..'
910
764
  )
911
765
  for item in offset_appls:
@@ -929,10 +783,6 @@ class SmileHelper:
929
783
  functionality = "thermostat_functionality"
930
784
  if item == "temperature_offset":
931
785
  functionality = "offset_functionality"
932
- # Don't support temperature_offset for legacy Anna
933
- if self._smile_legacy:
934
- continue
935
-
936
786
  # When there is no updated_date-text, skip the actuator
937
787
  updated_date_location = f'.//actuator_functionalities/{functionality}[type="{item}"]/updated_date'
938
788
  if (
@@ -1048,19 +898,16 @@ class SmileHelper:
1048
898
  Collect the appliance-data based on device id.
1049
899
  """
1050
900
  data: DeviceData = {"binary_sensors": {}, "sensors": {}, "switches": {}}
1051
- # Get P1 smartmeter data from LOCATIONS or MODULES
901
+ # Get P1 smartmeter data from LOCATIONS
1052
902
  device = self.gw_devices[dev_id]
1053
903
  # !! DON'T CHANGE below two if-lines, will break stuff !!
1054
904
  if self.smile_type == "power":
1055
905
  if device["dev_class"] == "smartmeter":
1056
- if not self._smile_legacy:
1057
- data.update(self._power_data_from_location(device["location"]))
1058
- else:
1059
- data.update(self._power_data_from_modules())
906
+ data.update(self._power_data_from_location(device["location"]))
1060
907
 
1061
908
  return data
1062
909
 
1063
- # Get non-p1 data from APPLIANCES, for legacy from DOMAIN_OBJECTS.
910
+ # Get non-P1 data from APPLIANCES
1064
911
  measurements = DEVICE_MEASUREMENTS
1065
912
  if self._is_thermostat and dev_id == self._heater_id:
1066
913
  measurements = HEATER_CENTRAL_MEASUREMENTS
@@ -1070,7 +917,7 @@ class SmileHelper:
1070
917
  # Counting of this item is done in _appliance_measurements()
1071
918
 
1072
919
  if (
1073
- appliance := self._appliances.find(f'./appliance[@id="{dev_id}"]')
920
+ appliance := self._domain_objects.find(f'./appliance[@id="{dev_id}"]')
1074
921
  ) is not None:
1075
922
  self._appliance_measurements(appliance, data, measurements)
1076
923
  self._get_lock_state(appliance, data)
@@ -1082,7 +929,7 @@ class SmileHelper:
1082
929
  self._get_actuator_functionalities(appliance, device, data)
1083
930
 
1084
931
  # Collect availability-status for wireless connected devices to Adam
1085
- self._wireless_availablity(appliance, data)
932
+ self._wireless_availability(appliance, data)
1086
933
 
1087
934
  if dev_id == self.gateway_id and self.smile(ADAM):
1088
935
  self._get_regulation_mode(appliance, data)
@@ -1137,33 +984,6 @@ class SmileHelper:
1137
984
 
1138
985
  return data
1139
986
 
1140
- def _rank_thermostat(
1141
- self,
1142
- thermo_matching: dict[str, int],
1143
- loc_id: str,
1144
- appliance_id: str,
1145
- appliance_details: DeviceData,
1146
- ) -> None:
1147
- """Helper-function for _scan_thermostats().
1148
-
1149
- Rank the thermostat based on appliance_details: master or slave.
1150
- """
1151
- appl_class = appliance_details["dev_class"]
1152
- appl_d_loc = appliance_details["location"]
1153
- if loc_id == appl_d_loc and appl_class in thermo_matching:
1154
- # Pre-elect new master
1155
- if thermo_matching[appl_class] > self._thermo_locs[loc_id]["master_prio"]:
1156
- # Demote former master
1157
- if (tl_master := self._thermo_locs[loc_id]["master"]) is not None:
1158
- self._thermo_locs[loc_id]["slaves"].add(tl_master)
1159
-
1160
- # Crown master
1161
- self._thermo_locs[loc_id]["master_prio"] = thermo_matching[appl_class]
1162
- self._thermo_locs[loc_id]["master"] = appliance_id
1163
-
1164
- else:
1165
- self._thermo_locs[loc_id]["slaves"].add(appliance_id)
1166
-
1167
987
  def _scan_thermostats(self) -> None:
1168
988
  """Helper-function for smile.py: get_all_devices().
1169
989
 
@@ -1190,26 +1010,56 @@ class SmileHelper:
1190
1010
  if "slaves" in tl_loc_id and dev_id in tl_loc_id["slaves"]:
1191
1011
  device["dev_class"] = "thermo_sensor"
1192
1012
 
1193
- def _thermostat_uri_legacy(self) -> str:
1194
- """Helper-function for _thermostat_uri().
1013
+ def _match_locations(self) -> dict[str, ThermoLoc]:
1014
+ """Helper-function for _scan_thermostats().
1195
1015
 
1196
- Determine the location-set_temperature uri - from APPLIANCES.
1016
+ Match appliances with locations.
1197
1017
  """
1198
- locator = "./appliance[type='thermostat']"
1199
- 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
1200
1028
 
1201
- 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)
1202
1055
 
1203
1056
  def _thermostat_uri(self, loc_id: str) -> str:
1204
1057
  """Helper-function for smile.py: set_temperature().
1205
1058
 
1206
1059
  Determine the location-set_temperature uri - from LOCATIONS.
1207
1060
  """
1208
- if self._smile_legacy:
1209
- return self._thermostat_uri_legacy()
1210
-
1211
1061
  locator = f'./location[@id="{loc_id}"]/actuator_functionalities/thermostat_functionality'
1212
- thermostat_functionality_id = self._locations.find(locator).attrib["id"]
1062
+ thermostat_functionality_id = self._domain_objects.find(locator).attrib["id"]
1213
1063
 
1214
1064
  return f"{LOCATIONS};id={loc_id}/thermostat;id={thermostat_functionality_id}"
1215
1065
 
@@ -1229,8 +1079,8 @@ class SmileHelper:
1229
1079
  group_name = group.find("name").text
1230
1080
  group_type = group.find("type").text
1231
1081
  group_appliances = group.findall("appliances/appliance")
1082
+ # Check if members are not orphaned
1232
1083
  for item in group_appliances:
1233
- # Check if members are not orphaned - stretch
1234
1084
  if item.attrib["id"] in self.gw_devices:
1235
1085
  members.append(item.attrib["id"])
1236
1086
 
@@ -1257,7 +1107,7 @@ class SmileHelper:
1257
1107
  """
1258
1108
  loc_found: int = 0
1259
1109
  open_valve_count: int = 0
1260
- for appliance in self._appliances.findall("./appliance"):
1110
+ for appliance in self._domain_objects.findall("./appliance"):
1261
1111
  locator = './logs/point_log[type="valve_position"]/period/measurement'
1262
1112
  if (appl_loc := appliance.find(locator)) is not None:
1263
1113
  loc_found += 1
@@ -1301,7 +1151,6 @@ class SmileHelper:
1301
1151
  """Helper-function for _power_data_from_location() and _power_data_from_modules()."""
1302
1152
  loc.found = True
1303
1153
  # If locator not found look for P1 gas_consumed or phase data (without tariff)
1304
- # or for P1 legacy electricity_point_meter or gas_*_meter data
1305
1154
  if loc.logs.find(loc.locator) is None:
1306
1155
  if "log" in loc.log_type and (
1307
1156
  "gas" in loc.measurement or "phase" in loc.measurement
@@ -1317,25 +1166,9 @@ class SmileHelper:
1317
1166
  if loc.logs.find(loc.locator) is None:
1318
1167
  loc.found = False
1319
1168
  return loc
1320
- # P1 legacy point_meter has no tariff_indicator
1321
- elif "meter" in loc.log_type and (
1322
- "point" in loc.log_type or "gas" in loc.measurement
1323
- ):
1324
- # Avoid double processing by skipping one peak-list option
1325
- if loc.peak_select == "nl_offpeak":
1326
- loc.found = False
1327
- return loc
1328
-
1329
- loc.locator = (
1330
- f"./{loc.meas_list[0]}_{loc.log_type}/"
1331
- f'measurement[@directionality="{loc.meas_list[1]}"]'
1332
- )
1333
- if loc.logs.find(loc.locator) is None:
1334
- loc.found = False
1335
- return loc
1336
1169
  else:
1337
- loc.found = False
1338
- return loc
1170
+ loc.found = False # pragma: no cover
1171
+ return loc # pragma: no cover
1339
1172
 
1340
1173
  if (peak := loc.peak_select.split("_")[1]) == "offpeak":
1341
1174
  peak = "off_peak"
@@ -1362,7 +1195,7 @@ class SmileHelper:
1362
1195
  peak_list: list[str] = ["nl_peak", "nl_offpeak"]
1363
1196
  t_string = "tariff"
1364
1197
 
1365
- search = self._locations
1198
+ search = self._domain_objects
1366
1199
  loc.logs = search.find(f'./location[@id="{loc_id}"]/logs')
1367
1200
  for loc.measurement, loc.attrs in P1_MEASUREMENTS.items():
1368
1201
  for loc.log_type in log_list:
@@ -1384,92 +1217,16 @@ class SmileHelper:
1384
1217
  self._count += len(direct_data["sensors"])
1385
1218
  return direct_data
1386
1219
 
1387
- def _power_data_from_modules(self) -> DeviceData:
1388
- """Helper-function for smile.py: _get_device_data().
1389
-
1390
- Collect the power-data from MODULES (P1 legacy only).
1391
- """
1392
- direct_data: DeviceData = {"sensors": {}}
1393
- loc = Munch()
1394
- mod_list: list[str] = ["interval_meter", "cumulative_meter", "point_meter"]
1395
- peak_list: list[str] = ["nl_peak", "nl_offpeak"]
1396
- t_string = "tariff_indicator"
1397
-
1398
- search = self._modules
1399
- mod_logs = search.findall("./module/services")
1400
- for loc.measurement, loc.attrs in P1_LEGACY_MEASUREMENTS.items():
1401
- loc.meas_list = loc.measurement.split("_")
1402
- for loc.logs in mod_logs:
1403
- for loc.log_type in mod_list:
1404
- for loc.peak_select in peak_list:
1405
- loc.locator = (
1406
- f"./{loc.meas_list[0]}_{loc.log_type}/measurement"
1407
- f'[@directionality="{loc.meas_list[1]}"][@{t_string}="{loc.peak_select}"]'
1408
- )
1409
- loc = self._power_data_peak_value(loc)
1410
- if not loc.found:
1411
- continue
1412
-
1413
- direct_data = self.power_data_energy_diff(
1414
- loc.measurement, loc.net_string, loc.f_val, direct_data
1415
- )
1416
- key = cast(SensorType, loc.key_string)
1417
- direct_data["sensors"][key] = loc.f_val
1418
-
1419
- self._count += len(direct_data["sensors"])
1420
- return direct_data
1421
-
1422
1220
  def _preset(self, loc_id: str) -> str | None:
1423
1221
  """Helper-function for smile.py: device_data_climate().
1424
1222
 
1425
1223
  Collect the active preset based on Location ID.
1426
1224
  """
1427
- if not self._smile_legacy:
1428
- locator = f'./location[@id="{loc_id}"]/preset'
1429
- if (preset := self._domain_objects.find(locator)) is not None:
1430
- return str(preset.text)
1431
- 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)
1432
1228
 
1433
- locator = "./rule[active='true']/directives/when/then"
1434
- if (
1435
- not (active_rule := etree_to_dict(self._domain_objects.find(locator)))
1436
- or "icon" not in active_rule
1437
- ):
1438
- return None
1439
-
1440
- return active_rule["icon"]
1441
-
1442
- def _schedules_legacy(
1443
- self,
1444
- avail: list[str],
1445
- location: str,
1446
- sel: str,
1447
- ) -> tuple[list[str], str]:
1448
- """Helper-function for _schedules().
1449
-
1450
- Collect available schedules/schedules for the legacy thermostat.
1451
- """
1452
- name: str | None = None
1453
-
1454
- search = self._domain_objects
1455
- for schedule in search.findall("./rule"):
1456
- if rule_name := schedule.find("name").text:
1457
- if "preset" not in rule_name:
1458
- name = rule_name
1459
-
1460
- log_type = "schedule_state"
1461
- locator = f"./appliance[type='thermostat']/logs/point_log[type='{log_type}']/period/measurement"
1462
- active = False
1463
- if (result := search.find(locator)) is not None:
1464
- active = result.text == "on"
1465
-
1466
- if name is not None:
1467
- avail = [name]
1468
- if active:
1469
- sel = name
1470
-
1471
- self._last_active[location] = "".join(map(str, avail))
1472
- return avail, sel
1229
+ return None # pragma: no cover
1473
1230
 
1474
1231
  def _schedules(self, location: str) -> tuple[list[str], str]:
1475
1232
  """Helper-function for smile.py: _device_data_climate().
@@ -1478,13 +1235,8 @@ class SmileHelper:
1478
1235
  NEW: when a location_id is present then the schedule is active. Valid for both Adam and non-legacy Anna.
1479
1236
  """
1480
1237
  available: list[str] = [NONE]
1481
- rule_ids: dict[str, str] = {}
1238
+ rule_ids: dict[str, dict[str, str]] = {}
1482
1239
  selected = NONE
1483
-
1484
- # Legacy Anna schedule, only one schedule allowed
1485
- if self._smile_legacy:
1486
- return self._schedules_legacy(available, location, selected)
1487
-
1488
1240
  # Adam schedules, one schedule can be linked to various locations
1489
1241
  # self._last_active contains the locations and the active schedule name per location, or None
1490
1242
  if location not in self._last_active:
@@ -1495,15 +1247,16 @@ class SmileHelper:
1495
1247
  return available, selected
1496
1248
 
1497
1249
  schedules: list[str] = []
1498
- for rule_id, loc_id in rule_ids.items():
1499
- name = self._domain_objects.find(f'./rule[@id="{rule_id}"]/name').text
1250
+ for rule_id, data in rule_ids.items():
1251
+ active = data["active"] == "true"
1252
+ name = data["name"]
1500
1253
  locator = f'./rule[@id="{rule_id}"]/directives'
1501
1254
  # Show an empty schedule as no schedule found
1502
1255
  if self._domain_objects.find(locator) is None:
1503
1256
  continue # pragma: no cover
1504
1257
 
1505
1258
  available.append(name)
1506
- if location == loc_id:
1259
+ if location == data["location"] and active:
1507
1260
  selected = name
1508
1261
  self._last_active[location] = selected
1509
1262
  schedules.append(name)
@@ -1542,7 +1295,6 @@ class SmileHelper:
1542
1295
  locator = f'./location[@id="{obj_id}"]/logs/point_log[type="{measurement}"]/period/measurement'
1543
1296
  if (found := search.find(locator)) is not None:
1544
1297
  val = format_measure(found.text, NONE)
1545
- return val
1546
1298
 
1547
1299
  return val
1548
1300
 
@@ -1553,9 +1305,6 @@ class SmileHelper:
1553
1305
  """
1554
1306
  actuator = "actuator_functionalities"
1555
1307
  func_type = "relay_functionality"
1556
- if self._stretch_v2:
1557
- actuator = "actuators"
1558
- func_type = "relay"
1559
1308
  if xml.find("type").text not in SPECIAL_PLUG_TYPES:
1560
1309
  locator = f"./{actuator}/{func_type}/lock"
1561
1310
  if (found := xml.find(locator)) is not None:
@@ -1574,8 +1323,19 @@ class SmileHelper:
1574
1323
  if (state := xml.find(locator)) is not None:
1575
1324
  data["switches"][name] = state.text == "on"
1576
1325
  self._count += 1
1577
- # Remove the cooling_enabled binary_sensor when the corresponding switch is present
1578
- # Except for Elga
1579
- if toggle == "cooling_enabled" and not self._elga:
1580
- data["binary_sensors"].pop("cooling_enabled")
1581
- 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
+ )