plugwise 1.6.4__py3-none-any.whl → 1.7.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/__init__.py +61 -53
- plugwise/common.py +20 -118
- plugwise/constants.py +3 -12
- plugwise/data.py +16 -17
- plugwise/helper.py +48 -257
- plugwise/legacy/data.py +7 -12
- plugwise/legacy/helper.py +19 -50
- plugwise/legacy/smile.py +30 -45
- plugwise/smile.py +29 -44
- plugwise/smilecomm.py +148 -0
- plugwise/util.py +112 -21
- {plugwise-1.6.4.dist-info → plugwise-1.7.0.dist-info}/METADATA +2 -2
- plugwise-1.7.0.dist-info/RECORD +18 -0
- {plugwise-1.6.4.dist-info → plugwise-1.7.0.dist-info}/WHEEL +1 -1
- plugwise-1.6.4.dist-info/RECORD +0 -17
- {plugwise-1.6.4.dist-info → plugwise-1.7.0.dist-info}/LICENSE +0 -0
- {plugwise-1.6.4.dist-info → plugwise-1.7.0.dist-info}/top_level.txt +0 -0
plugwise/legacy/helper.py
CHANGED
@@ -30,13 +30,14 @@ from plugwise.constants import (
|
|
30
30
|
ActuatorDataType,
|
31
31
|
ActuatorType,
|
32
32
|
ApplianceType,
|
33
|
-
GatewayData,
|
34
33
|
GwEntityData,
|
35
34
|
SensorType,
|
36
35
|
ThermoLoc,
|
37
36
|
)
|
38
37
|
from plugwise.util import (
|
38
|
+
collect_power_values,
|
39
39
|
common_match_cases,
|
40
|
+
count_data_items,
|
40
41
|
format_measure,
|
41
42
|
skip_obsolete_measurements,
|
42
43
|
version_to_model,
|
@@ -63,32 +64,15 @@ class SmileLegacyHelper(SmileCommon):
|
|
63
64
|
def __init__(self) -> None:
|
64
65
|
"""Set the constructor for this class."""
|
65
66
|
self._appliances: etree
|
66
|
-
self._count: int
|
67
|
-
self._domain_objects: etree
|
68
|
-
self._heater_id: str
|
69
|
-
self._home_location: str
|
70
67
|
self._is_thermostat: bool
|
71
|
-
self._last_modified: dict[str, str] = {}
|
72
68
|
self._loc_data: dict[str, ThermoLoc]
|
73
69
|
self._locations: etree
|
74
70
|
self._modules: etree
|
75
|
-
self._notifications: dict[str, dict[str, str]] = {}
|
76
|
-
self._on_off_device: bool
|
77
|
-
self._opentherm_device: bool
|
78
|
-
self._outdoor_temp: float
|
79
|
-
self._status: etree
|
80
71
|
self._stretch_v2: bool
|
81
|
-
self._system: etree
|
82
|
-
|
83
|
-
self.gateway_id: str
|
84
|
-
self.gw_data: GatewayData = {}
|
85
72
|
self.gw_entities: dict[str, GwEntityData] = {}
|
86
|
-
self.smile_fw_version: Version | None
|
87
|
-
self.smile_hw_version: str | None
|
88
73
|
self.smile_mac_address: str | None
|
89
74
|
self.smile_model: str
|
90
|
-
self.
|
91
|
-
self.smile_type: str
|
75
|
+
self.smile_version: Version | None
|
92
76
|
self.smile_zigbee_mac_address: str | None
|
93
77
|
SmileCommon.__init__(self)
|
94
78
|
|
@@ -115,7 +99,7 @@ class SmileLegacyHelper(SmileCommon):
|
|
115
99
|
):
|
116
100
|
continue # pragma: no cover
|
117
101
|
|
118
|
-
appl.location = self.
|
102
|
+
appl.location = self._home_loc_id
|
119
103
|
appl.entity_id = appliance.attrib["id"]
|
120
104
|
appl.name = appliance.find("name").text
|
121
105
|
# Extend device_class name when a Circle/Stealth is type heater_central -- Pw-Beta Issue #739
|
@@ -161,7 +145,7 @@ class SmileLegacyHelper(SmileCommon):
|
|
161
145
|
|
162
146
|
# Legacy Anna without outdoor_temp and Stretches have no locations, create fake location-data
|
163
147
|
if not (locations := self._locations.findall("./location")):
|
164
|
-
self.
|
148
|
+
self._home_loc_id = FAKE_LOC
|
165
149
|
self._loc_data[FAKE_LOC] = {"name": "Home"}
|
166
150
|
return
|
167
151
|
|
@@ -174,11 +158,11 @@ class SmileLegacyHelper(SmileCommon):
|
|
174
158
|
continue
|
175
159
|
|
176
160
|
if loc.name == "Home":
|
177
|
-
self.
|
161
|
+
self._home_loc_id = loc.loc_id
|
178
162
|
# Replace location-name for P1 legacy, can contain privacy-related info
|
179
163
|
if self.smile_type == "power":
|
180
164
|
loc.name = "Home"
|
181
|
-
self.
|
165
|
+
self._home_loc_id = loc.loc_id
|
182
166
|
|
183
167
|
self._loc_data[loc.loc_id] = {"name": loc.name}
|
184
168
|
|
@@ -187,15 +171,15 @@ class SmileLegacyHelper(SmileCommon):
|
|
187
171
|
|
188
172
|
Use the home_location or FAKE_APPL as entity id.
|
189
173
|
"""
|
190
|
-
self.gateway_id = self.
|
174
|
+
self.gateway_id = self._home_loc_id
|
191
175
|
if self.smile_type == "power":
|
192
176
|
self.gateway_id = FAKE_APPL
|
193
177
|
|
194
178
|
self.gw_entities[self.gateway_id] = {"dev_class": "gateway"}
|
195
179
|
self._count += 1
|
196
180
|
for key, value in {
|
197
|
-
"firmware": str(self.
|
198
|
-
"location": self.
|
181
|
+
"firmware": str(self.smile_version),
|
182
|
+
"location": self._home_loc_id,
|
199
183
|
"mac_address": self.smile_mac_address,
|
200
184
|
"model": self.smile_model,
|
201
185
|
"name": self.smile_name,
|
@@ -272,7 +256,7 @@ class SmileLegacyHelper(SmileCommon):
|
|
272
256
|
Collect the appliance-data based on entity_id.
|
273
257
|
"""
|
274
258
|
data: GwEntityData = {"binary_sensors": {}, "sensors": {}, "switches": {}}
|
275
|
-
# Get P1 smartmeter data from
|
259
|
+
# Get P1 smartmeter data from MODULES
|
276
260
|
entity = self.gw_entities[entity_id]
|
277
261
|
# !! DON'T CHANGE below two if-lines, will break stuff !!
|
278
262
|
if self.smile_type == "power":
|
@@ -294,14 +278,13 @@ class SmileLegacyHelper(SmileCommon):
|
|
294
278
|
if appliance.find("type").text in ACTUATOR_CLASSES:
|
295
279
|
self._get_actuator_functionalities(appliance, entity, data)
|
296
280
|
|
297
|
-
#
|
298
|
-
#
|
281
|
+
# Anna: the Smile outdoor_temperature is present in the Home location
|
282
|
+
# For some Anna's LOCATIONS is empty, falling back to domain_objects!
|
299
283
|
if self._is_thermostat and entity_id == self.gateway_id:
|
300
|
-
|
301
|
-
|
302
|
-
|
303
|
-
|
304
|
-
data.update({"sensors": {"outdoor_temperature": outdoor_temperature}})
|
284
|
+
locator = f"./location[@id='{self._home_loc_id}']/logs/point_log[type='outdoor_temperature']/period/measurement"
|
285
|
+
if (found := self._domain_objects.find(locator)) is not None:
|
286
|
+
value = format_measure(found.text, NONE)
|
287
|
+
data.update({"sensors": {"outdoor_temperature": value}})
|
305
288
|
self._count += 1
|
306
289
|
|
307
290
|
if "c_heating_state" in data:
|
@@ -326,7 +309,7 @@ class SmileLegacyHelper(SmileCommon):
|
|
326
309
|
loc.meas_list = loc.measurement.split("_")
|
327
310
|
for loc.logs in mod_logs:
|
328
311
|
for loc.log_type in mod_list:
|
329
|
-
|
312
|
+
collect_power_values(data, loc, t_string, legacy=True)
|
330
313
|
|
331
314
|
self._count += len(data["sensors"])
|
332
315
|
return data
|
@@ -359,7 +342,7 @@ class SmileLegacyHelper(SmileCommon):
|
|
359
342
|
appl_i_loc.text, ENERGY_WATT_HOUR
|
360
343
|
)
|
361
344
|
|
362
|
-
self.
|
345
|
+
self._count = count_data_items(self._count, data)
|
363
346
|
|
364
347
|
def _get_actuator_functionalities(
|
365
348
|
self, xml: etree, entity: GwEntityData, data: GwEntityData
|
@@ -396,20 +379,6 @@ class SmileLegacyHelper(SmileCommon):
|
|
396
379
|
act_item = cast(ActuatorType, item)
|
397
380
|
data[act_item] = temp_dict
|
398
381
|
|
399
|
-
def _object_value(self, obj_id: str, measurement: str) -> float | int | None:
|
400
|
-
"""Helper-function for smile.py: _get_entity_data().
|
401
|
-
|
402
|
-
Obtain the value/state for the given object from a location in DOMAIN_OBJECTS
|
403
|
-
"""
|
404
|
-
val: float | int | None = None
|
405
|
-
search = self._domain_objects
|
406
|
-
locator = f'./location[@id="{obj_id}"]/logs/point_log[type="{measurement}"]/period/measurement'
|
407
|
-
if (found := search.find(locator)) is not None:
|
408
|
-
val = format_measure(found.text, NONE)
|
409
|
-
return val
|
410
|
-
|
411
|
-
return val
|
412
|
-
|
413
382
|
def _preset(self) -> str | None:
|
414
383
|
"""Helper-function for smile.py: _climate_data().
|
415
384
|
|
plugwise/legacy/smile.py
CHANGED
@@ -11,8 +11,6 @@ from typing import Any
|
|
11
11
|
|
12
12
|
from plugwise.constants import (
|
13
13
|
APPLIANCES,
|
14
|
-
DEFAULT_PORT,
|
15
|
-
DEFAULT_USERNAME,
|
16
14
|
DOMAIN_OBJECTS,
|
17
15
|
LOCATIONS,
|
18
16
|
LOGGER,
|
@@ -20,46 +18,40 @@ from plugwise.constants import (
|
|
20
18
|
OFF,
|
21
19
|
REQUIRE_APPLIANCES,
|
22
20
|
RULES,
|
23
|
-
GatewayData,
|
24
21
|
GwEntityData,
|
25
|
-
|
22
|
+
SmileProps,
|
26
23
|
ThermoLoc,
|
27
24
|
)
|
28
25
|
from plugwise.exceptions import ConnectionFailedError, DataMissingError, PlugwiseError
|
29
26
|
from plugwise.legacy.data import SmileLegacyData
|
30
27
|
|
31
|
-
import aiohttp
|
32
28
|
from munch import Munch
|
33
29
|
from packaging.version import Version
|
34
30
|
|
35
31
|
|
36
32
|
class SmileLegacyAPI(SmileLegacyData):
|
37
|
-
"""The Plugwise SmileLegacyAPI class."""
|
33
|
+
"""The Plugwise SmileLegacyAPI helper class for actual Plugwise legacy devices."""
|
38
34
|
|
39
35
|
# pylint: disable=too-many-instance-attributes, too-many-public-methods
|
40
36
|
|
41
37
|
def __init__(
|
42
38
|
self,
|
43
|
-
host: str,
|
44
|
-
password: str,
|
45
|
-
request: Callable[..., Awaitable[Any]],
|
46
|
-
websession: aiohttp.ClientSession,
|
47
39
|
_is_thermostat: bool,
|
48
40
|
_loc_data: dict[str, ThermoLoc],
|
49
41
|
_on_off_device: bool,
|
50
42
|
_opentherm_device: bool,
|
43
|
+
_request: Callable[..., Awaitable[Any]],
|
44
|
+
_smile_props: SmileProps,
|
51
45
|
_stretch_v2: bool,
|
52
46
|
_target_smile: str,
|
53
|
-
smile_fw_version: Version | None,
|
54
47
|
smile_hostname: str,
|
55
48
|
smile_hw_version: str | None,
|
56
49
|
smile_mac_address: str | None,
|
57
50
|
smile_model: str,
|
58
51
|
smile_name: str,
|
59
52
|
smile_type: str,
|
53
|
+
smile_version: Version | None,
|
60
54
|
smile_zigbee_mac_address: str | None,
|
61
|
-
port: int = DEFAULT_PORT,
|
62
|
-
username: str = DEFAULT_USERNAME,
|
63
55
|
) -> None:
|
64
56
|
"""Set the constructor for this class."""
|
65
57
|
self._cooling_present = False
|
@@ -67,59 +59,55 @@ class SmileLegacyAPI(SmileLegacyData):
|
|
67
59
|
self._loc_data = _loc_data
|
68
60
|
self._on_off_device = _on_off_device
|
69
61
|
self._opentherm_device = _opentherm_device
|
62
|
+
self._request = _request
|
63
|
+
self._smile_props = _smile_props
|
70
64
|
self._stretch_v2 = _stretch_v2
|
71
65
|
self._target_smile = _target_smile
|
72
|
-
self.request = request
|
73
|
-
self.smile_fw_version = smile_fw_version
|
74
66
|
self.smile_hostname = smile_hostname
|
75
67
|
self.smile_hw_version = smile_hw_version
|
76
68
|
self.smile_mac_address = smile_mac_address
|
77
69
|
self.smile_model = smile_model
|
78
70
|
self.smile_name = smile_name
|
79
71
|
self.smile_type = smile_type
|
72
|
+
self.smile_version = smile_version
|
80
73
|
self.smile_zigbee_mac_address = smile_zigbee_mac_address
|
81
74
|
SmileLegacyData.__init__(self)
|
82
75
|
|
76
|
+
self._first_update = True
|
83
77
|
self._previous_day_number: str = "0"
|
84
78
|
|
85
79
|
async def full_xml_update(self) -> None:
|
86
|
-
"""Perform a first fetch of
|
87
|
-
self._domain_objects = await self.
|
88
|
-
self._locations = await self.
|
89
|
-
self._modules = await self.
|
80
|
+
"""Perform a first fetch of the Plugwise server XML data."""
|
81
|
+
self._domain_objects = await self._request(DOMAIN_OBJECTS)
|
82
|
+
self._locations = await self._request(LOCATIONS)
|
83
|
+
self._modules = await self._request(MODULES)
|
90
84
|
# P1 legacy has no appliances
|
91
85
|
if self.smile_type != "power":
|
92
|
-
self._appliances = await self.
|
86
|
+
self._appliances = await self._request(APPLIANCES)
|
93
87
|
|
94
88
|
def get_all_gateway_entities(self) -> None:
|
95
|
-
"""Collect the gateway entities from the received raw XML-data.
|
89
|
+
"""Collect the Plugwise gateway entities and their data and states from the received raw XML-data.
|
96
90
|
|
97
|
-
|
98
|
-
|
91
|
+
First, collect all the connected entities and their initial data.
|
92
|
+
Collect and add switching- and/or pump-group entities.
|
93
|
+
Finally, collect the data and states for each entity.
|
99
94
|
"""
|
100
|
-
# Gather all the devices and their initial data
|
101
95
|
self._all_appliances()
|
102
|
-
|
103
|
-
# Collect and add switching- and/or pump-group devices
|
104
96
|
if group_data := self._get_group_switches():
|
105
97
|
self.gw_entities.update(group_data)
|
106
98
|
|
107
|
-
# Collect the remaining data for all entities
|
108
99
|
self._all_entity_data()
|
109
100
|
|
110
|
-
async def async_update(self) ->
|
111
|
-
"""Perform an
|
112
|
-
|
101
|
+
async def async_update(self) -> dict[str, GwEntityData]:
|
102
|
+
"""Perform an full update update at day-change: re-collect all gateway entities and their data and states.
|
103
|
+
|
104
|
+
Otherwise perform an incremental update: only collect the entities updated data and states.
|
105
|
+
"""
|
113
106
|
day_number = dt.datetime.now().strftime("%w")
|
114
|
-
if
|
115
|
-
day_number # pylint: disable=consider-using-assignment-expr
|
116
|
-
!= self._previous_day_number
|
117
|
-
):
|
107
|
+
if self._first_update or day_number != self._previous_day_number:
|
118
108
|
LOGGER.info(
|
119
109
|
"Performing daily full-update, reload the Plugwise integration when a single entity becomes unavailable."
|
120
110
|
)
|
121
|
-
self.gw_data: GatewayData = {}
|
122
|
-
self.gw_entities: dict[str, GwEntityData] = {}
|
123
111
|
try:
|
124
112
|
await self.full_xml_update()
|
125
113
|
self.get_all_gateway_entities()
|
@@ -129,15 +117,14 @@ class SmileLegacyAPI(SmileLegacyData):
|
|
129
117
|
raise DataMissingError(
|
130
118
|
"No (full) Plugwise legacy data received"
|
131
119
|
) from err
|
132
|
-
# Otherwise perform an incremental update
|
133
120
|
else:
|
134
121
|
try:
|
135
|
-
self._domain_objects = await self.
|
122
|
+
self._domain_objects = await self._request(DOMAIN_OBJECTS)
|
136
123
|
match self._target_smile:
|
137
124
|
case "smile_v2":
|
138
|
-
self._modules = await self.
|
125
|
+
self._modules = await self._request(MODULES)
|
139
126
|
case self._target_smile if self._target_smile in REQUIRE_APPLIANCES:
|
140
|
-
self._appliances = await self.
|
127
|
+
self._appliances = await self._request(APPLIANCES)
|
141
128
|
|
142
129
|
self._update_gw_entities()
|
143
130
|
# Detect failed data-retrieval
|
@@ -145,11 +132,9 @@ class SmileLegacyAPI(SmileLegacyData):
|
|
145
132
|
except KeyError as err: # pragma: no cover
|
146
133
|
raise DataMissingError("No legacy Plugwise data received") from err
|
147
134
|
|
135
|
+
self._first_update = False
|
148
136
|
self._previous_day_number = day_number
|
149
|
-
return
|
150
|
-
devices=self.gw_entities,
|
151
|
-
gateway=self.gw_data,
|
152
|
-
)
|
137
|
+
return self.gw_entities
|
153
138
|
|
154
139
|
########################################################################################################
|
155
140
|
### API Set and HA Service-related Functions ###
|
@@ -309,6 +294,6 @@ class SmileLegacyAPI(SmileLegacyData):
|
|
309
294
|
method: str = kwargs["method"]
|
310
295
|
data: str | None = kwargs.get("data")
|
311
296
|
try:
|
312
|
-
await self.
|
297
|
+
await self._request(uri, method=method, data=data)
|
313
298
|
except ConnectionFailedError as exc:
|
314
299
|
raise ConnectionFailedError from exc
|
plugwise/smile.py
CHANGED
@@ -13,8 +13,6 @@ from plugwise.constants import (
|
|
13
13
|
ADAM,
|
14
14
|
ANNA,
|
15
15
|
APPLIANCES,
|
16
|
-
DEFAULT_PORT,
|
17
|
-
DEFAULT_USERNAME,
|
18
16
|
DOMAIN_OBJECTS,
|
19
17
|
GATEWAY_REBOOT,
|
20
18
|
LOCATIONS,
|
@@ -23,15 +21,13 @@ from plugwise.constants import (
|
|
23
21
|
NOTIFICATIONS,
|
24
22
|
OFF,
|
25
23
|
RULES,
|
26
|
-
GatewayData,
|
27
24
|
GwEntityData,
|
28
|
-
|
25
|
+
SmileProps,
|
29
26
|
ThermoLoc,
|
30
27
|
)
|
31
28
|
from plugwise.data import SmileData
|
32
29
|
from plugwise.exceptions import ConnectionFailedError, DataMissingError, PlugwiseError
|
33
30
|
|
34
|
-
import aiohttp
|
35
31
|
from defusedxml import ElementTree as etree
|
36
32
|
|
37
33
|
# Dict as class
|
@@ -46,10 +42,6 @@ class SmileAPI(SmileData):
|
|
46
42
|
|
47
43
|
def __init__(
|
48
44
|
self,
|
49
|
-
host: str,
|
50
|
-
password: str,
|
51
|
-
request: Callable[..., Awaitable[Any]],
|
52
|
-
websession: aiohttp.ClientSession,
|
53
45
|
_cooling_present: bool,
|
54
46
|
_elga: bool,
|
55
47
|
_is_thermostat: bool,
|
@@ -57,9 +49,9 @@ class SmileAPI(SmileData):
|
|
57
49
|
_loc_data: dict[str, ThermoLoc],
|
58
50
|
_on_off_device: bool,
|
59
51
|
_opentherm_device: bool,
|
52
|
+
_request: Callable[..., Awaitable[Any]],
|
60
53
|
_schedule_old_states: dict[str, dict[str, str]],
|
61
|
-
|
62
|
-
smile_fw_version: Version | None,
|
54
|
+
_smile_props: SmileProps,
|
63
55
|
smile_hostname: str | None,
|
64
56
|
smile_hw_version: str | None,
|
65
57
|
smile_mac_address: str | None,
|
@@ -68,23 +60,18 @@ class SmileAPI(SmileData):
|
|
68
60
|
smile_name: str,
|
69
61
|
smile_type: str,
|
70
62
|
smile_version: Version | None,
|
71
|
-
port: int = DEFAULT_PORT,
|
72
|
-
username: str = DEFAULT_USERNAME,
|
73
63
|
) -> None:
|
74
64
|
"""Set the constructor for this class."""
|
75
|
-
self._cooling_enabled = False
|
76
65
|
self._cooling_present = _cooling_present
|
77
66
|
self._elga = _elga
|
78
|
-
self._heater_id: str
|
79
67
|
self._is_thermostat = _is_thermostat
|
80
68
|
self._last_active = _last_active
|
81
69
|
self._loc_data = _loc_data
|
82
70
|
self._on_off_device = _on_off_device
|
83
71
|
self._opentherm_device = _opentherm_device
|
72
|
+
self._request = _request
|
84
73
|
self._schedule_old_states = _schedule_old_states
|
85
|
-
self.
|
86
|
-
self.request = request
|
87
|
-
self.smile_fw_version = smile_fw_version
|
74
|
+
self._smile_props = _smile_props
|
88
75
|
self.smile_hostname = smile_hostname
|
89
76
|
self.smile_hw_version = smile_hw_version
|
90
77
|
self.smile_mac_address = smile_mac_address
|
@@ -93,48 +80,49 @@ class SmileAPI(SmileData):
|
|
93
80
|
self.smile_name = smile_name
|
94
81
|
self.smile_type = smile_type
|
95
82
|
self.smile_version = smile_version
|
83
|
+
self.therms_with_offset_func: list[str] = []
|
96
84
|
SmileData.__init__(self)
|
97
85
|
|
98
86
|
async def full_xml_update(self) -> None:
|
99
|
-
"""Perform a first fetch of
|
100
|
-
self._domain_objects = await self.
|
87
|
+
"""Perform a first fetch of the Plugwise server XML data."""
|
88
|
+
self._domain_objects = await self._request(DOMAIN_OBJECTS)
|
101
89
|
self._get_plugwise_notifications()
|
102
90
|
|
103
91
|
def get_all_gateway_entities(self) -> None:
|
104
|
-
"""Collect the gateway entities from the received raw XML-data.
|
92
|
+
"""Collect the Plugwise gateway entities and their data and states from the received raw XML-data.
|
105
93
|
|
106
|
-
|
107
|
-
|
94
|
+
First, collect all the connected entities and their initial data.
|
95
|
+
If a thermostat-gateway, collect a list of thermostats with offset-capability.
|
96
|
+
Collect and add switching- and/or pump-group entities.
|
97
|
+
Finally, collect the data and states for each entity.
|
108
98
|
"""
|
109
|
-
# Gather all the entities and their initial data
|
110
99
|
self._all_appliances()
|
111
100
|
if self._is_thermostat:
|
112
|
-
if self.smile(ADAM):
|
113
|
-
self._scan_thermostats()
|
114
|
-
# Collect a list of thermostats with offset-capability
|
115
101
|
self.therms_with_offset_func = (
|
116
102
|
self._get_appliances_with_offset_functionality()
|
117
103
|
)
|
104
|
+
if self.smile(ADAM):
|
105
|
+
self._scan_thermostats()
|
118
106
|
|
119
|
-
# Collect and add switching- and/or pump-group devices
|
120
107
|
if group_data := self._get_group_switches():
|
121
108
|
self.gw_entities.update(group_data)
|
122
109
|
|
123
|
-
# Collect the remaining data for all entities
|
124
110
|
self._all_entity_data()
|
125
111
|
|
126
|
-
async def async_update(self) ->
|
127
|
-
"""Perform an
|
128
|
-
|
129
|
-
|
130
|
-
|
112
|
+
async def async_update(self) -> dict[str, GwEntityData]:
|
113
|
+
"""Perform an full update: re-collect all gateway entities and their data and states.
|
114
|
+
|
115
|
+
Any change in the connected entities will be detected immediately.
|
116
|
+
"""
|
117
|
+
self._zones = {}
|
118
|
+
self.gw_entities = {}
|
131
119
|
try:
|
132
120
|
await self.full_xml_update()
|
133
121
|
self.get_all_gateway_entities()
|
134
|
-
# Set self._cooling_enabled - required for set_temperature,
|
122
|
+
# Set self._cooling_enabled - required for set_temperature(),
|
135
123
|
# also, check for a failed data-retrieval
|
136
|
-
if "heater_id" in self.
|
137
|
-
heat_cooler = self.gw_entities[self.
|
124
|
+
if "heater_id" in self._smile_props:
|
125
|
+
heat_cooler = self.gw_entities[self._smile_props["heater_id"]]
|
138
126
|
if (
|
139
127
|
"binary_sensors" in heat_cooler
|
140
128
|
and "cooling_enabled" in heat_cooler["binary_sensors"]
|
@@ -143,14 +131,11 @@ class SmileAPI(SmileData):
|
|
143
131
|
"cooling_enabled"
|
144
132
|
]
|
145
133
|
else: # cover failed data-retrieval for P1
|
146
|
-
_ = self.gw_entities[self.gateway_id]["location"]
|
134
|
+
_ = self.gw_entities[self._smile_props["gateway_id"]]["location"]
|
147
135
|
except KeyError as err:
|
148
136
|
raise DataMissingError("No Plugwise actual data received") from err
|
149
137
|
|
150
|
-
return
|
151
|
-
devices=self.gw_entities,
|
152
|
-
gateway=self.gw_data,
|
153
|
-
)
|
138
|
+
return self.gw_entities
|
154
139
|
|
155
140
|
########################################################################################################
|
156
141
|
### API Set and HA Service-related Functions ###
|
@@ -274,7 +259,7 @@ class SmileAPI(SmileData):
|
|
274
259
|
vacation_time = time_2 + "T23:00:00.000Z"
|
275
260
|
valid = f"<valid_from>{vacation_time}</valid_from><valid_to>{end_time}</valid_to>"
|
276
261
|
|
277
|
-
uri = f"{APPLIANCES};id={self.gateway_id}/gateway_mode_control"
|
262
|
+
uri = f"{APPLIANCES};id={self._smile_props['gateway_id']}/gateway_mode_control"
|
278
263
|
data = f"<gateway_mode_control_functionality><mode>{mode}</mode>{valid}</gateway_mode_control_functionality>"
|
279
264
|
|
280
265
|
await self.call_request(uri, method="put", data=data)
|
@@ -476,6 +461,6 @@ class SmileAPI(SmileData):
|
|
476
461
|
method: str = kwargs["method"]
|
477
462
|
data: str | None = kwargs.get("data")
|
478
463
|
try:
|
479
|
-
await self.
|
464
|
+
await self._request(uri, method=method, data=data)
|
480
465
|
except ConnectionFailedError as exc:
|
481
466
|
raise ConnectionFailedError from exc
|
plugwise/smilecomm.py
ADDED
@@ -0,0 +1,148 @@
|
|
1
|
+
"""Use of this source code is governed by the MIT license found in the LICENSE file.
|
2
|
+
|
3
|
+
Plugwise Smile communication protocol helpers.
|
4
|
+
"""
|
5
|
+
|
6
|
+
from __future__ import annotations
|
7
|
+
|
8
|
+
from plugwise.constants import LOGGER
|
9
|
+
from plugwise.exceptions import (
|
10
|
+
ConnectionFailedError,
|
11
|
+
InvalidAuthentication,
|
12
|
+
InvalidXMLError,
|
13
|
+
ResponseError,
|
14
|
+
)
|
15
|
+
from plugwise.util import escape_illegal_xml_characters
|
16
|
+
|
17
|
+
# This way of importing aiohttp is because of patch/mocking in testing (aiohttp timeouts)
|
18
|
+
from aiohttp import BasicAuth, ClientError, ClientResponse, ClientSession, ClientTimeout
|
19
|
+
from defusedxml import ElementTree as etree
|
20
|
+
|
21
|
+
|
22
|
+
class SmileComm:
|
23
|
+
"""The SmileComm class."""
|
24
|
+
|
25
|
+
def __init__(
|
26
|
+
self,
|
27
|
+
host: str,
|
28
|
+
password: str,
|
29
|
+
port: int,
|
30
|
+
timeout: int,
|
31
|
+
username: str,
|
32
|
+
websession: ClientSession | None,
|
33
|
+
) -> None:
|
34
|
+
"""Set the constructor for this class."""
|
35
|
+
if not websession:
|
36
|
+
aio_timeout = ClientTimeout(total=timeout)
|
37
|
+
self._websession = ClientSession(timeout=aio_timeout)
|
38
|
+
else:
|
39
|
+
self._websession = websession
|
40
|
+
|
41
|
+
# Quickfix IPv6 formatting, not covering
|
42
|
+
if host.count(":") > 2: # pragma: no cover
|
43
|
+
host = f"[{host}]"
|
44
|
+
|
45
|
+
self._auth = BasicAuth(username, password=password)
|
46
|
+
self._endpoint = f"http://{host}:{str(port)}" # Sensitive
|
47
|
+
|
48
|
+
async def _request(
|
49
|
+
self,
|
50
|
+
command: str,
|
51
|
+
retry: int = 3,
|
52
|
+
method: str = "get",
|
53
|
+
data: str | None = None,
|
54
|
+
) -> etree:
|
55
|
+
"""Get/put/delete data from a give URL."""
|
56
|
+
resp: ClientResponse
|
57
|
+
url = f"{self._endpoint}{command}"
|
58
|
+
try:
|
59
|
+
match method:
|
60
|
+
case "delete":
|
61
|
+
resp = await self._websession.delete(url, auth=self._auth)
|
62
|
+
case "get":
|
63
|
+
# Work-around for Stretchv2, should not hurt the other smiles
|
64
|
+
headers = {"Accept-Encoding": "gzip"}
|
65
|
+
resp = await self._websession.get(
|
66
|
+
url, headers=headers, auth=self._auth
|
67
|
+
)
|
68
|
+
case "post":
|
69
|
+
headers = {"Content-type": "text/xml"}
|
70
|
+
resp = await self._websession.post(
|
71
|
+
url,
|
72
|
+
headers=headers,
|
73
|
+
data=data,
|
74
|
+
auth=self._auth,
|
75
|
+
)
|
76
|
+
case "put":
|
77
|
+
headers = {"Content-type": "text/xml"}
|
78
|
+
resp = await self._websession.put(
|
79
|
+
url,
|
80
|
+
headers=headers,
|
81
|
+
data=data,
|
82
|
+
auth=self._auth,
|
83
|
+
)
|
84
|
+
except (
|
85
|
+
ClientError
|
86
|
+
) as exc: # ClientError is an ancestor class of ServerTimeoutError
|
87
|
+
if retry < 1:
|
88
|
+
LOGGER.warning(
|
89
|
+
"Failed sending %s %s to Plugwise Smile, error: %s",
|
90
|
+
method,
|
91
|
+
command,
|
92
|
+
exc,
|
93
|
+
)
|
94
|
+
raise ConnectionFailedError from exc
|
95
|
+
return await self._request(command, retry - 1)
|
96
|
+
|
97
|
+
if resp.status == 504:
|
98
|
+
if retry < 1:
|
99
|
+
LOGGER.warning(
|
100
|
+
"Failed sending %s %s to Plugwise Smile, error: %s",
|
101
|
+
method,
|
102
|
+
command,
|
103
|
+
"504 Gateway Timeout",
|
104
|
+
)
|
105
|
+
raise ConnectionFailedError
|
106
|
+
return await self._request(command, retry - 1)
|
107
|
+
|
108
|
+
return await self._request_validate(resp, method)
|
109
|
+
|
110
|
+
async def _request_validate(self, resp: ClientResponse, method: str) -> etree:
|
111
|
+
"""Helper-function for _request(): validate the returned data."""
|
112
|
+
match resp.status:
|
113
|
+
case 200:
|
114
|
+
# Cornercases for server not responding with 202
|
115
|
+
if method in ("post", "put"):
|
116
|
+
return
|
117
|
+
case 202:
|
118
|
+
# Command accepted gives empty body with status 202
|
119
|
+
return
|
120
|
+
case 401:
|
121
|
+
msg = (
|
122
|
+
"Invalid Plugwise login, please retry with the correct credentials."
|
123
|
+
)
|
124
|
+
LOGGER.error("%s", msg)
|
125
|
+
raise InvalidAuthentication
|
126
|
+
case 405:
|
127
|
+
msg = "405 Method not allowed."
|
128
|
+
LOGGER.error("%s", msg)
|
129
|
+
raise ConnectionFailedError
|
130
|
+
|
131
|
+
if not (result := await resp.text()) or (
|
132
|
+
"<error>" in result and "Not started" not in result
|
133
|
+
):
|
134
|
+
LOGGER.warning("Smile response empty or error in %s", result)
|
135
|
+
raise ResponseError
|
136
|
+
|
137
|
+
try:
|
138
|
+
# Encode to ensure utf8 parsing
|
139
|
+
xml = etree.XML(escape_illegal_xml_characters(result).encode())
|
140
|
+
except etree.ParseError as exc:
|
141
|
+
LOGGER.warning("Smile returns invalid XML for %s", self._endpoint)
|
142
|
+
raise InvalidXMLError from exc
|
143
|
+
|
144
|
+
return xml
|
145
|
+
|
146
|
+
async def close_connection(self) -> None:
|
147
|
+
"""Close the Plugwise connection."""
|
148
|
+
await self._websession.close()
|