plugwise 1.6.2__py3-none-any.whl → 1.6.4__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- plugwise/__init__.py +89 -63
- plugwise/common.py +20 -13
- plugwise/constants.py +3 -4
- plugwise/data.py +62 -37
- plugwise/helper.py +178 -100
- plugwise/legacy/data.py +13 -0
- plugwise/legacy/helper.py +12 -13
- plugwise/legacy/smile.py +36 -17
- plugwise/smile.py +23 -10
- plugwise/util.py +5 -7
- {plugwise-1.6.2.dist-info → plugwise-1.6.4.dist-info}/METADATA +1 -1
- plugwise-1.6.4.dist-info/RECORD +17 -0
- plugwise-1.6.2.dist-info/RECORD +0 -17
- {plugwise-1.6.2.dist-info → plugwise-1.6.4.dist-info}/LICENSE +0 -0
- {plugwise-1.6.2.dist-info → plugwise-1.6.4.dist-info}/WHEEL +0 -0
- {plugwise-1.6.2.dist-info → plugwise-1.6.4.dist-info}/top_level.txt +0 -0
plugwise/helper.py
CHANGED
@@ -2,6 +2,7 @@
|
|
2
2
|
|
3
3
|
Plugwise Smile protocol helpers.
|
4
4
|
"""
|
5
|
+
|
5
6
|
from __future__ import annotations
|
6
7
|
|
7
8
|
import asyncio
|
@@ -28,6 +29,7 @@ from plugwise.constants import (
|
|
28
29
|
NONE,
|
29
30
|
OFF,
|
30
31
|
P1_MEASUREMENTS,
|
32
|
+
PRIORITY_DEVICE_CLASSES,
|
31
33
|
TEMP_CELSIUS,
|
32
34
|
THERMOSTAT_CLASSES,
|
33
35
|
TOGGLES,
|
@@ -67,6 +69,15 @@ from munch import Munch
|
|
67
69
|
from packaging import version
|
68
70
|
|
69
71
|
|
72
|
+
def search_actuator_functionalities(appliance: etree, actuator: str) -> etree | None:
|
73
|
+
"""Helper-function for finding the relevant actuator xml-structure."""
|
74
|
+
locator = f"./actuator_functionalities/{actuator}"
|
75
|
+
if (search := appliance.find(locator)) is not None:
|
76
|
+
return search
|
77
|
+
|
78
|
+
return None
|
79
|
+
|
80
|
+
|
70
81
|
class SmileComm:
|
71
82
|
"""The SmileComm class."""
|
72
83
|
|
@@ -179,7 +190,9 @@ class SmileComm:
|
|
179
190
|
# Command accepted gives empty body with status 202
|
180
191
|
return
|
181
192
|
case 401:
|
182
|
-
msg =
|
193
|
+
msg = (
|
194
|
+
"Invalid Plugwise login, please retry with the correct credentials."
|
195
|
+
)
|
183
196
|
LOGGER.error("%s", msg)
|
184
197
|
raise InvalidAuthentication
|
185
198
|
case 405:
|
@@ -266,7 +279,11 @@ class SmileHelper(SmileCommon):
|
|
266
279
|
SmileCommon.__init__(self)
|
267
280
|
|
268
281
|
def _all_appliances(self) -> None:
|
269
|
-
"""Collect all appliances with relevant info.
|
282
|
+
"""Collect all appliances with relevant info.
|
283
|
+
|
284
|
+
Also, collect the P1 smartmeter info from a location
|
285
|
+
as this one is not available as an appliance.
|
286
|
+
"""
|
270
287
|
self._count = 0
|
271
288
|
self._all_locations()
|
272
289
|
|
@@ -318,55 +335,33 @@ class SmileHelper(SmileCommon):
|
|
318
335
|
if not (appl := self._appliance_info_finder(appl, appliance)):
|
319
336
|
continue
|
320
337
|
|
321
|
-
# P1: for gateway and smartmeter switch entity_id - part 1
|
322
|
-
# This is done to avoid breakage in HA Core
|
323
|
-
if appl.pwclass == "gateway" and self.smile_type == "power":
|
324
|
-
appl.entity_id = appl.location
|
325
|
-
|
326
338
|
self._create_gw_entities(appl)
|
327
339
|
|
328
|
-
# For P1 collect the connected SmartMeter info
|
329
340
|
if self.smile_type == "power":
|
330
|
-
self.
|
331
|
-
# P1: for gateway and smartmeter switch entity_id - part 2
|
332
|
-
for item in self.gw_entities:
|
333
|
-
if item != self.gateway_id:
|
334
|
-
self.gateway_id = item
|
335
|
-
# Leave for-loop to avoid a 2nd device_id switch
|
336
|
-
break
|
337
|
-
|
338
|
-
# Place the gateway and optional heater_central devices as 1st and 2nd
|
339
|
-
for dev_class in ("heater_central", "gateway"):
|
340
|
-
for entity_id, entity in dict(self.gw_entities).items():
|
341
|
-
if entity["dev_class"] == dev_class:
|
342
|
-
tmp_entity = entity
|
343
|
-
self.gw_entities.pop(entity_id)
|
344
|
-
cleared_dict = self.gw_entities
|
345
|
-
add_to_front = {entity_id: tmp_entity}
|
346
|
-
self.gw_entities = {**add_to_front, **cleared_dict}
|
341
|
+
self._get_p1_smartmeter_info()
|
347
342
|
|
348
|
-
|
349
|
-
|
350
|
-
loc = Munch()
|
351
|
-
locations = self._domain_objects.findall("./location")
|
352
|
-
for location in locations:
|
353
|
-
loc.name = location.find("name").text
|
354
|
-
loc.loc_id = location.attrib["id"]
|
355
|
-
if loc.name == "Home":
|
356
|
-
self._home_location = loc.loc_id
|
343
|
+
# Sort the gw_entities
|
344
|
+
self._sort_gw_entities()
|
357
345
|
|
358
|
-
|
346
|
+
def _get_p1_smartmeter_info(self) -> None:
|
347
|
+
"""For P1 collect the connected SmartMeter info from the Home/building location.
|
359
348
|
|
360
|
-
|
361
|
-
|
349
|
+
Note: For P1, the entity_id for the gateway and smartmeter are
|
350
|
+
switched to maintain backward compatibility with existing implementations.
|
351
|
+
"""
|
352
|
+
appl = Munch()
|
362
353
|
loc_id = next(iter(self._loc_data.keys()))
|
363
|
-
|
354
|
+
if (
|
355
|
+
location := self._domain_objects.find(f'./location[@id="{loc_id}"]')
|
356
|
+
) is None:
|
357
|
+
return
|
358
|
+
|
364
359
|
locator = MODULE_LOCATOR
|
365
360
|
module_data = self._get_module_data(location, locator)
|
366
361
|
if not module_data["contents"]:
|
367
362
|
LOGGER.error("No module data found for SmartMeter") # pragma: no cover
|
368
|
-
return
|
369
|
-
|
363
|
+
return # pragma: no cover
|
364
|
+
appl.available = None
|
370
365
|
appl.entity_id = self.gateway_id
|
371
366
|
appl.firmware = module_data["firmware_version"]
|
372
367
|
appl.hardware = module_data["hardware_version"]
|
@@ -379,21 +374,52 @@ class SmileHelper(SmileCommon):
|
|
379
374
|
appl.vendor_name = module_data["vendor_name"]
|
380
375
|
appl.zigbee_mac = None
|
381
376
|
|
377
|
+
# Replace the entity_id of the gateway by the smartmeter location_id
|
378
|
+
self.gw_entities[loc_id] = self.gw_entities.pop(self.gateway_id)
|
379
|
+
self.gateway_id = loc_id
|
380
|
+
|
382
381
|
self._create_gw_entities(appl)
|
383
382
|
|
383
|
+
def _sort_gw_entities(self) -> None:
|
384
|
+
"""Place the gateway and optional heater_central entities as 1st and 2nd."""
|
385
|
+
for dev_class in PRIORITY_DEVICE_CLASSES:
|
386
|
+
for entity_id, entity in dict(self.gw_entities).items():
|
387
|
+
if entity["dev_class"] == dev_class:
|
388
|
+
priority_entity = entity
|
389
|
+
self.gw_entities.pop(entity_id)
|
390
|
+
other_entities = self.gw_entities
|
391
|
+
priority_entities = {entity_id: priority_entity}
|
392
|
+
self.gw_entities = {**priority_entities, **other_entities}
|
393
|
+
|
394
|
+
def _all_locations(self) -> None:
|
395
|
+
"""Collect all locations."""
|
396
|
+
loc = Munch()
|
397
|
+
locations = self._domain_objects.findall("./location")
|
398
|
+
for location in locations:
|
399
|
+
loc.name = location.find("name").text
|
400
|
+
loc.loc_id = location.attrib["id"]
|
401
|
+
if loc.name == "Home":
|
402
|
+
self._home_location = loc.loc_id
|
403
|
+
|
404
|
+
self._loc_data[loc.loc_id] = {"name": loc.name}
|
405
|
+
|
384
406
|
def _appliance_info_finder(self, appl: Munch, appliance: etree) -> Munch:
|
385
407
|
"""Collect info for all appliances found."""
|
386
408
|
match appl.pwclass:
|
387
409
|
case "gateway":
|
388
|
-
# Collect gateway
|
410
|
+
# Collect gateway entity info
|
389
411
|
return self._appl_gateway_info(appl, appliance)
|
390
412
|
case _ as dev_class if dev_class in THERMOSTAT_CLASSES:
|
391
|
-
# Collect thermostat
|
413
|
+
# Collect thermostat entity info
|
392
414
|
return self._appl_thermostat_info(appl, appliance)
|
393
415
|
case "heater_central":
|
394
|
-
# Collect heater_central
|
395
|
-
self._appl_heater_central_info(
|
396
|
-
|
416
|
+
# Collect heater_central entity info
|
417
|
+
self._appl_heater_central_info(
|
418
|
+
appl, appliance, False
|
419
|
+
) # False means non-legacy entity
|
420
|
+
self._dhw_allowed_modes = self._get_appl_actuator_modes(
|
421
|
+
appliance, "domestic_hot_water_mode_control_functionality"
|
422
|
+
)
|
397
423
|
# Skip orphaned heater_central (Core Issue #104433)
|
398
424
|
if appl.entity_id != self._heater_id:
|
399
425
|
return Munch()
|
@@ -430,11 +456,15 @@ class SmileHelper(SmileCommon):
|
|
430
456
|
|
431
457
|
# Adam: collect the ZigBee MAC address of the Smile
|
432
458
|
if self.smile(ADAM):
|
433
|
-
if (
|
459
|
+
if (
|
460
|
+
found := self._domain_objects.find(".//protocols/zig_bee_coordinator")
|
461
|
+
) is not None:
|
434
462
|
appl.zigbee_mac = found.find("mac_address").text
|
435
463
|
|
436
464
|
# Also, collect regulation_modes and check for cooling, indicating cooling-mode is present
|
437
|
-
self.
|
465
|
+
self._reg_allowed_modes = self._get_appl_actuator_modes(
|
466
|
+
appliance, "regulation_mode_control_functionality"
|
467
|
+
)
|
438
468
|
|
439
469
|
# Finally, collect the gateway_modes
|
440
470
|
self._gw_allowed_modes = []
|
@@ -445,32 +475,24 @@ class SmileHelper(SmileCommon):
|
|
445
475
|
|
446
476
|
return appl
|
447
477
|
|
448
|
-
def
|
449
|
-
|
450
|
-
|
451
|
-
|
452
|
-
|
453
|
-
|
454
|
-
|
455
|
-
|
456
|
-
|
457
|
-
|
458
|
-
self.
|
459
|
-
|
460
|
-
def _appl_dhw_mode_info(self, appl: Munch, appliance: etree) -> Munch:
|
461
|
-
"""Helper-function for _appliance_info_finder().
|
462
|
-
|
463
|
-
Collect dhw control operation modes - Anna + Loria.
|
464
|
-
"""
|
465
|
-
dhw_mode_list: list[str] = []
|
466
|
-
locator = "./actuator_functionalities/domestic_hot_water_mode_control_functionality"
|
467
|
-
if (search := appliance.find(locator)) is not None:
|
468
|
-
if search.find("allowed_modes") is not None:
|
469
|
-
for mode in search.find("allowed_modes"):
|
470
|
-
dhw_mode_list.append(mode.text)
|
471
|
-
self._dhw_allowed_modes = dhw_mode_list
|
478
|
+
def _get_appl_actuator_modes(
|
479
|
+
self, appliance: etree, actuator_type: str
|
480
|
+
) -> list[str]:
|
481
|
+
"""Get allowed modes for the given actuator type."""
|
482
|
+
mode_list: list[str] = []
|
483
|
+
if (
|
484
|
+
search := search_actuator_functionalities(appliance, actuator_type)
|
485
|
+
) is not None and (modes := search.find("allowed_modes")) is not None:
|
486
|
+
for mode in modes:
|
487
|
+
mode_list.append(mode.text)
|
488
|
+
self._check_cooling_mode(mode.text)
|
472
489
|
|
473
|
-
return
|
490
|
+
return mode_list
|
491
|
+
|
492
|
+
def _check_cooling_mode(self, mode: str) -> None:
|
493
|
+
"""Check if cooling mode is present and update state."""
|
494
|
+
if mode == "cooling":
|
495
|
+
self._cooling_present = True
|
474
496
|
|
475
497
|
def _get_appliances_with_offset_functionality(self) -> list[str]:
|
476
498
|
"""Helper-function collecting all appliance that have offset_functionality."""
|
@@ -510,7 +532,7 @@ class SmileHelper(SmileCommon):
|
|
510
532
|
# !! DON'T CHANGE below two if-lines, will break stuff !!
|
511
533
|
if self.smile_type == "power":
|
512
534
|
if entity["dev_class"] == "smartmeter":
|
513
|
-
|
535
|
+
data.update(self._power_data_from_location(entity["location"]))
|
514
536
|
|
515
537
|
return data
|
516
538
|
|
@@ -636,13 +658,19 @@ class SmileHelper(SmileCommon):
|
|
636
658
|
def _get_actuator_functionalities(
|
637
659
|
self, xml: etree, entity: GwEntityData, data: GwEntityData
|
638
660
|
) -> None:
|
639
|
-
"""
|
661
|
+
"""Get and process the actuator_functionalities details for an entity.
|
662
|
+
|
663
|
+
Add the resulting dict(s) to the entity's data.
|
664
|
+
"""
|
640
665
|
for item in ACTIVE_ACTUATORS:
|
641
666
|
# Skip max_dhw_temperature, not initially valid,
|
642
667
|
# skip thermostat for all but zones with thermostats
|
643
668
|
if item == "max_dhw_temperature" or (
|
644
|
-
item == "thermostat"
|
645
|
-
|
669
|
+
item == "thermostat"
|
670
|
+
and (
|
671
|
+
entity["dev_class"] != "climate"
|
672
|
+
if self.smile(ADAM)
|
673
|
+
else entity["dev_class"] != "thermostat"
|
646
674
|
)
|
647
675
|
):
|
648
676
|
continue
|
@@ -689,6 +717,21 @@ class SmileHelper(SmileCommon):
|
|
689
717
|
act_item = cast(ActuatorType, item)
|
690
718
|
data[act_item] = temp_dict
|
691
719
|
|
720
|
+
def _get_actuator_mode(
|
721
|
+
self, appliance: etree, entity_id: str, key: str
|
722
|
+
) -> str | None:
|
723
|
+
"""Helper-function for _get_regulation_mode and _get_gateway_mode.
|
724
|
+
|
725
|
+
Collect the requested gateway mode.
|
726
|
+
"""
|
727
|
+
if not (self.smile(ADAM) and entity_id == self.gateway_id):
|
728
|
+
return None
|
729
|
+
|
730
|
+
if (search := search_actuator_functionalities(appliance, key)) is not None:
|
731
|
+
return str(search.find("mode").text)
|
732
|
+
|
733
|
+
return None
|
734
|
+
|
692
735
|
def _get_regulation_mode(
|
693
736
|
self, appliance: etree, entity_id: str, data: GwEntityData
|
694
737
|
) -> None:
|
@@ -696,14 +739,14 @@ class SmileHelper(SmileCommon):
|
|
696
739
|
|
697
740
|
Adam: collect the gateway regulation_mode.
|
698
741
|
"""
|
699
|
-
if
|
700
|
-
|
701
|
-
|
702
|
-
|
703
|
-
|
704
|
-
data["select_regulation_mode"] =
|
742
|
+
if (
|
743
|
+
mode := self._get_actuator_mode(
|
744
|
+
appliance, entity_id, "regulation_mode_control_functionality"
|
745
|
+
)
|
746
|
+
) is not None:
|
747
|
+
data["select_regulation_mode"] = mode
|
705
748
|
self._count += 1
|
706
|
-
self._cooling_enabled =
|
749
|
+
self._cooling_enabled = mode == "cooling"
|
707
750
|
|
708
751
|
def _get_gateway_mode(
|
709
752
|
self, appliance: etree, entity_id: str, data: GwEntityData
|
@@ -712,12 +755,12 @@ class SmileHelper(SmileCommon):
|
|
712
755
|
|
713
756
|
Adam: collect the gateway mode.
|
714
757
|
"""
|
715
|
-
if
|
716
|
-
|
717
|
-
|
718
|
-
|
719
|
-
|
720
|
-
data["select_gateway_mode"] =
|
758
|
+
if (
|
759
|
+
mode := self._get_actuator_mode(
|
760
|
+
appliance, entity_id, "gateway_mode_control_functionality"
|
761
|
+
)
|
762
|
+
) is not None:
|
763
|
+
data["select_gateway_mode"] = mode
|
721
764
|
self._count += 1
|
722
765
|
|
723
766
|
def _get_gateway_outdoor_temp(self, entity_id: str, data: GwEntityData) -> None:
|
@@ -768,19 +811,23 @@ class SmileHelper(SmileCommon):
|
|
768
811
|
data["binary_sensors"]["heating_state"] = data["c_heating_state"]
|
769
812
|
|
770
813
|
if self.smile(ADAM):
|
814
|
+
# First count when not present, then create and init to False.
|
815
|
+
# When present init to False
|
771
816
|
if "heating_state" not in data["binary_sensors"]:
|
772
817
|
self._count += 1
|
773
818
|
data["binary_sensors"]["heating_state"] = False
|
819
|
+
|
774
820
|
if "cooling_state" not in data["binary_sensors"]:
|
775
821
|
self._count += 1
|
776
822
|
data["binary_sensors"]["cooling_state"] = False
|
823
|
+
|
777
824
|
if self._cooling_enabled:
|
778
825
|
data["binary_sensors"]["cooling_state"] = data["c_heating_state"]
|
779
826
|
else:
|
780
827
|
data["binary_sensors"]["heating_state"] = data["c_heating_state"]
|
781
828
|
|
782
829
|
def _update_anna_cooling(self, entity_id: str, data: GwEntityData) -> None:
|
783
|
-
"""Update the Anna heater_central
|
830
|
+
"""Update the Anna heater_central entity for cooling.
|
784
831
|
|
785
832
|
Support added for Techneco Elga and Thercon Loria/Thermastage.
|
786
833
|
"""
|
@@ -798,7 +845,11 @@ class SmileHelper(SmileCommon):
|
|
798
845
|
# Techneco Elga has cooling-capability
|
799
846
|
self._cooling_present = True
|
800
847
|
data["model"] = "Generic heater/cooler"
|
801
|
-
|
848
|
+
# Cooling_enabled in xml does NOT show the correct status!
|
849
|
+
# Setting it specifically:
|
850
|
+
self._cooling_enabled = data["binary_sensors"]["cooling_enabled"] = data[
|
851
|
+
"elga_status_code"
|
852
|
+
] in (8, 9)
|
802
853
|
data["binary_sensors"]["cooling_state"] = self._cooling_active = (
|
803
854
|
data["elga_status_code"] == 8
|
804
855
|
)
|
@@ -812,11 +863,17 @@ class SmileHelper(SmileCommon):
|
|
812
863
|
|
813
864
|
def _update_loria_cooling(self, data: GwEntityData) -> None:
|
814
865
|
"""Loria/Thermastage: base cooling-related on cooling_state and modulation_level."""
|
815
|
-
|
866
|
+
# For Loria/Thermastage it's not clear if cooling_enabled in xml shows the correct status,
|
867
|
+
# setting it specifically:
|
868
|
+
self._cooling_enabled = data["binary_sensors"]["cooling_enabled"] = data[
|
869
|
+
"binary_sensors"
|
870
|
+
]["cooling_state"]
|
816
871
|
self._cooling_active = data["sensors"]["modulation_level"] == 100
|
817
872
|
# For Loria the above does not work (pw-beta issue #301)
|
818
873
|
if "cooling_ena_switch" in data["switches"]:
|
819
|
-
self._cooling_enabled = data["
|
874
|
+
self._cooling_enabled = data["binary_sensors"]["cooling_enabled"] = data[
|
875
|
+
"switches"
|
876
|
+
]["cooling_ena_switch"]
|
820
877
|
self._cooling_active = data["binary_sensors"]["cooling_state"]
|
821
878
|
|
822
879
|
def _cleanup_data(self, data: GwEntityData) -> None:
|
@@ -866,7 +923,10 @@ class SmileHelper(SmileCommon):
|
|
866
923
|
"dev_class": "climate",
|
867
924
|
"model": "ThermoZone",
|
868
925
|
"name": loc_data["name"],
|
869
|
-
"thermostats": {
|
926
|
+
"thermostats": {
|
927
|
+
"primary": loc_data["primary"],
|
928
|
+
"secondary": loc_data["secondary"],
|
929
|
+
},
|
870
930
|
"vendor": "Plugwise",
|
871
931
|
}
|
872
932
|
self._count += 3
|
@@ -906,10 +966,12 @@ class SmileHelper(SmileCommon):
|
|
906
966
|
if thermo_matching[appl_class] == thermo_loc["primary_prio"]:
|
907
967
|
thermo_loc["primary"].append(appliance_id)
|
908
968
|
# Pre-elect new primary
|
909
|
-
elif (thermo_rank := thermo_matching[appl_class]) > thermo_loc[
|
969
|
+
elif (thermo_rank := thermo_matching[appl_class]) > thermo_loc[
|
970
|
+
"primary_prio"
|
971
|
+
]:
|
910
972
|
thermo_loc["primary_prio"] = thermo_rank
|
911
973
|
# Demote former primary
|
912
|
-
if
|
974
|
+
if tl_primary := thermo_loc["primary"]:
|
913
975
|
thermo_loc["secondary"] += tl_primary
|
914
976
|
thermo_loc["primary"] = []
|
915
977
|
|
@@ -1006,9 +1068,17 @@ class SmileHelper(SmileCommon):
|
|
1006
1068
|
for rule in self._domain_objects.findall(f'./rule[name="{name}"]'):
|
1007
1069
|
active = rule.find("active").text
|
1008
1070
|
if rule.find(locator) is not None:
|
1009
|
-
schedule_ids[rule.attrib["id"]] = {
|
1071
|
+
schedule_ids[rule.attrib["id"]] = {
|
1072
|
+
"location": loc_id,
|
1073
|
+
"name": name,
|
1074
|
+
"active": active,
|
1075
|
+
}
|
1010
1076
|
else:
|
1011
|
-
schedule_ids[rule.attrib["id"]] = {
|
1077
|
+
schedule_ids[rule.attrib["id"]] = {
|
1078
|
+
"location": NONE,
|
1079
|
+
"name": name,
|
1080
|
+
"active": active,
|
1081
|
+
}
|
1012
1082
|
|
1013
1083
|
return schedule_ids
|
1014
1084
|
|
@@ -1025,9 +1095,17 @@ class SmileHelper(SmileCommon):
|
|
1025
1095
|
name = rule.find("name").text
|
1026
1096
|
active = rule.find("active").text
|
1027
1097
|
if rule.find(locator2) is not None:
|
1028
|
-
schedule_ids[rule.attrib["id"]] = {
|
1098
|
+
schedule_ids[rule.attrib["id"]] = {
|
1099
|
+
"location": loc_id,
|
1100
|
+
"name": name,
|
1101
|
+
"active": active,
|
1102
|
+
}
|
1029
1103
|
else:
|
1030
|
-
schedule_ids[rule.attrib["id"]] = {
|
1104
|
+
schedule_ids[rule.attrib["id"]] = {
|
1105
|
+
"location": NONE,
|
1106
|
+
"name": name,
|
1107
|
+
"active": active,
|
1108
|
+
}
|
1031
1109
|
|
1032
1110
|
return schedule_ids
|
1033
1111
|
|
plugwise/legacy/data.py
CHANGED
@@ -2,6 +2,7 @@
|
|
2
2
|
|
3
3
|
Plugwise Smile protocol data-collection helpers for legacy devices.
|
4
4
|
"""
|
5
|
+
|
5
6
|
from __future__ import annotations
|
6
7
|
|
7
8
|
# Dict as class
|
@@ -63,6 +64,7 @@ class SmileLegacyData(SmileLegacyHelper):
|
|
63
64
|
|
64
65
|
# Thermostat data (presets, temperatures etc)
|
65
66
|
self._climate_data(entity, data)
|
67
|
+
self._get_anna_control_state(data)
|
66
68
|
|
67
69
|
return data
|
68
70
|
|
@@ -91,3 +93,14 @@ class SmileLegacyData(SmileLegacyHelper):
|
|
91
93
|
self._count += 1
|
92
94
|
if sel_schedule in (NONE, OFF):
|
93
95
|
data["climate_mode"] = "heat"
|
96
|
+
|
97
|
+
def _get_anna_control_state(self, data: GwEntityData) -> None:
|
98
|
+
"""Set the thermostat control_state based on the opentherm/onoff device state."""
|
99
|
+
data["control_state"] = "idle"
|
100
|
+
for entity in self.gw_entities.values():
|
101
|
+
if entity["dev_class"] != "heater_central":
|
102
|
+
continue
|
103
|
+
|
104
|
+
binary_sensors = entity["binary_sensors"]
|
105
|
+
if binary_sensors["heating_state"]:
|
106
|
+
data["control_state"] = "heating"
|
plugwise/legacy/helper.py
CHANGED
@@ -2,6 +2,7 @@
|
|
2
2
|
|
3
3
|
Plugwise Smile protocol helpers.
|
4
4
|
"""
|
5
|
+
|
5
6
|
from __future__ import annotations
|
6
7
|
|
7
8
|
from typing import cast
|
@@ -169,10 +170,7 @@ class SmileLegacyHelper(SmileCommon):
|
|
169
170
|
loc.loc_id = location.attrib["id"]
|
170
171
|
# Filter the valid single location for P1 legacy: services not empty
|
171
172
|
locator = "./services"
|
172
|
-
if (
|
173
|
-
self.smile_type == "power"
|
174
|
-
and len(location.find(locator)) == 0
|
175
|
-
):
|
173
|
+
if self.smile_type == "power" and len(location.find(locator)) == 0:
|
176
174
|
continue
|
177
175
|
|
178
176
|
if loc.name == "Home":
|
@@ -212,15 +210,15 @@ class SmileLegacyHelper(SmileCommon):
|
|
212
210
|
def _appliance_info_finder(self, appliance: etree, appl: Munch) -> Munch:
|
213
211
|
"""Collect entity info (Smile/Stretch, Thermostats, OpenTherm/On-Off): firmware, model and vendor name."""
|
214
212
|
match appl.pwclass:
|
215
|
-
|
213
|
+
# Collect thermostat entity info
|
216
214
|
case _ as dev_class if dev_class in THERMOSTAT_CLASSES:
|
217
215
|
return self._appl_thermostat_info(appl, appliance, self._modules)
|
218
|
-
|
216
|
+
# Collect heater_central entity info
|
219
217
|
case "heater_central":
|
220
218
|
return self._appl_heater_central_info(
|
221
219
|
appl, appliance, True, self._appliances, self._modules
|
222
220
|
) # True means legacy device
|
223
|
-
|
221
|
+
# Collect info from Stretches
|
224
222
|
case _:
|
225
223
|
return self._energy_entity_info_finder(appliance, appl)
|
226
224
|
|
@@ -231,7 +229,9 @@ class SmileLegacyHelper(SmileCommon):
|
|
231
229
|
"""
|
232
230
|
if self.smile_type in ("power", "stretch"):
|
233
231
|
locator = "./services/electricity_point_meter"
|
234
|
-
module_data = self._get_module_data(
|
232
|
+
module_data = self._get_module_data(
|
233
|
+
appliance, locator, self._modules, legacy=True
|
234
|
+
)
|
235
235
|
appl.zigbee_mac = module_data["zigbee_mac_address"]
|
236
236
|
# Filter appliance without zigbee_mac, it's an orphaned device
|
237
237
|
if appl.zigbee_mac is None and self.smile_type != "power":
|
@@ -362,10 +362,7 @@ class SmileLegacyHelper(SmileCommon):
|
|
362
362
|
self._count_data_items(data)
|
363
363
|
|
364
364
|
def _get_actuator_functionalities(
|
365
|
-
self,
|
366
|
-
xml: etree,
|
367
|
-
entity: GwEntityData,
|
368
|
-
data: GwEntityData
|
365
|
+
self, xml: etree, entity: GwEntityData, data: GwEntityData
|
369
366
|
) -> None:
|
370
367
|
"""Helper-function for _get_measurement_data()."""
|
371
368
|
for item in ACTIVE_ACTUATORS:
|
@@ -458,7 +455,9 @@ class SmileLegacyHelper(SmileCommon):
|
|
458
455
|
active = result.text == "on"
|
459
456
|
|
460
457
|
# Show an empty schedule as no schedule found
|
461
|
-
directives =
|
458
|
+
directives = (
|
459
|
+
search.find(f'./rule[@id="{rule_id}"]/directives/when/then') is not None
|
460
|
+
)
|
462
461
|
if directives and name is not None:
|
463
462
|
available = [name, OFF]
|
464
463
|
selected = name if active else OFF
|
plugwise/legacy/smile.py
CHANGED
@@ -2,6 +2,7 @@
|
|
2
2
|
|
3
3
|
Plugwise backend module for Home Assistant Core - covering the legacy P1, Anna, and Stretch devices.
|
4
4
|
"""
|
5
|
+
|
5
6
|
from __future__ import annotations
|
6
7
|
|
7
8
|
from collections.abc import Awaitable, Callable
|
@@ -24,7 +25,7 @@ from plugwise.constants import (
|
|
24
25
|
PlugwiseData,
|
25
26
|
ThermoLoc,
|
26
27
|
)
|
27
|
-
from plugwise.exceptions import ConnectionFailedError, PlugwiseError
|
28
|
+
from plugwise.exceptions import ConnectionFailedError, DataMissingError, PlugwiseError
|
28
29
|
from plugwise.legacy.data import SmileLegacyData
|
29
30
|
|
30
31
|
import aiohttp
|
@@ -119,18 +120,30 @@ class SmileLegacyAPI(SmileLegacyData):
|
|
119
120
|
)
|
120
121
|
self.gw_data: GatewayData = {}
|
121
122
|
self.gw_entities: dict[str, GwEntityData] = {}
|
122
|
-
|
123
|
-
|
123
|
+
try:
|
124
|
+
await self.full_xml_update()
|
125
|
+
self.get_all_gateway_entities()
|
126
|
+
# Detect failed data-retrieval
|
127
|
+
_ = self.gw_entities[self.gateway_id]["location"]
|
128
|
+
except KeyError as err: # pragma: no cover
|
129
|
+
raise DataMissingError(
|
130
|
+
"No (full) Plugwise legacy data received"
|
131
|
+
) from err
|
124
132
|
# Otherwise perform an incremental update
|
125
133
|
else:
|
126
|
-
|
127
|
-
|
128
|
-
|
129
|
-
|
130
|
-
|
131
|
-
self.
|
132
|
-
|
133
|
-
|
134
|
+
try:
|
135
|
+
self._domain_objects = await self.request(DOMAIN_OBJECTS)
|
136
|
+
match self._target_smile:
|
137
|
+
case "smile_v2":
|
138
|
+
self._modules = await self.request(MODULES)
|
139
|
+
case self._target_smile if self._target_smile in REQUIRE_APPLIANCES:
|
140
|
+
self._appliances = await self.request(APPLIANCES)
|
141
|
+
|
142
|
+
self._update_gw_entities()
|
143
|
+
# Detect failed data-retrieval
|
144
|
+
_ = self.gw_entities[self.gateway_id]["location"]
|
145
|
+
except KeyError as err: # pragma: no cover
|
146
|
+
raise DataMissingError("No legacy Plugwise data received") from err
|
134
147
|
|
135
148
|
self._previous_day_number = day_number
|
136
149
|
return PlugwiseData(
|
@@ -138,9 +151,9 @@ class SmileLegacyAPI(SmileLegacyData):
|
|
138
151
|
gateway=self.gw_data,
|
139
152
|
)
|
140
153
|
|
141
|
-
########################################################################################################
|
142
|
-
### API Set and HA Service-related Functions ###
|
143
|
-
########################################################################################################
|
154
|
+
########################################################################################################
|
155
|
+
### API Set and HA Service-related Functions ###
|
156
|
+
########################################################################################################
|
144
157
|
|
145
158
|
async def delete_notification(self) -> None:
|
146
159
|
"""Set-function placeholder for legacy devices."""
|
@@ -181,12 +194,16 @@ class SmileLegacyAPI(SmileLegacyData):
|
|
181
194
|
async def set_regulation_mode(self, mode: str) -> None:
|
182
195
|
"""Set-function placeholder for legacy devices."""
|
183
196
|
|
184
|
-
async def set_select(
|
197
|
+
async def set_select(
|
198
|
+
self, key: str, loc_id: str, option: str, state: str | None
|
199
|
+
) -> None:
|
185
200
|
"""Set the thermostat schedule option."""
|
186
201
|
# schedule name corresponds to select option
|
187
202
|
await self.set_schedule_state("dummy", state, option)
|
188
203
|
|
189
|
-
async def set_schedule_state(
|
204
|
+
async def set_schedule_state(
|
205
|
+
self, _: str, state: str | None, name: str | None
|
206
|
+
) -> None:
|
190
207
|
"""Activate/deactivate the Schedule.
|
191
208
|
|
192
209
|
Determined from - DOMAIN_OBJECTS.
|
@@ -205,7 +222,9 @@ class SmileLegacyAPI(SmileLegacyData):
|
|
205
222
|
schedule_rule_id = rule.attrib["id"]
|
206
223
|
|
207
224
|
if schedule_rule_id is None:
|
208
|
-
raise PlugwiseError(
|
225
|
+
raise PlugwiseError(
|
226
|
+
"Plugwise: no schedule with this name available."
|
227
|
+
) # pragma: no cover
|
209
228
|
|
210
229
|
new_state = "false"
|
211
230
|
if state == "on":
|