plugwise 1.6.3__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 +134 -100
- plugwise/common.py +32 -123
- plugwise/constants.py +6 -16
- plugwise/data.py +60 -37
- plugwise/helper.py +198 -333
- plugwise/legacy/data.py +20 -12
- plugwise/legacy/helper.py +31 -63
- plugwise/legacy/smile.py +64 -60
- plugwise/smile.py +50 -54
- plugwise/smilecomm.py +148 -0
- plugwise/util.py +117 -28
- {plugwise-1.6.3.dist-info → plugwise-1.7.0.dist-info}/METADATA +2 -2
- plugwise-1.7.0.dist-info/RECORD +18 -0
- {plugwise-1.6.3.dist-info → plugwise-1.7.0.dist-info}/WHEEL +1 -1
- plugwise-1.6.3.dist-info/RECORD +0 -17
- {plugwise-1.6.3.dist-info → plugwise-1.7.0.dist-info}/LICENSE +0 -0
- {plugwise-1.6.3.dist-info → plugwise-1.7.0.dist-info}/top_level.txt +0 -0
plugwise/helper.py
CHANGED
@@ -2,9 +2,9 @@
|
|
2
2
|
|
3
3
|
Plugwise Smile protocol helpers.
|
4
4
|
"""
|
5
|
+
|
5
6
|
from __future__ import annotations
|
6
7
|
|
7
|
-
import asyncio
|
8
8
|
import datetime as dt
|
9
9
|
from typing import cast
|
10
10
|
|
@@ -28,6 +28,7 @@ from plugwise.constants import (
|
|
28
28
|
NONE,
|
29
29
|
OFF,
|
30
30
|
P1_MEASUREMENTS,
|
31
|
+
PRIORITY_DEVICE_CLASSES,
|
31
32
|
TEMP_CELSIUS,
|
32
33
|
THERMOSTAT_CLASSES,
|
33
34
|
TOGGLES,
|
@@ -36,29 +37,20 @@ from plugwise.constants import (
|
|
36
37
|
ActuatorData,
|
37
38
|
ActuatorDataType,
|
38
39
|
ActuatorType,
|
39
|
-
GatewayData,
|
40
40
|
GwEntityData,
|
41
41
|
SensorType,
|
42
42
|
ThermoLoc,
|
43
43
|
ToggleNameType,
|
44
44
|
)
|
45
|
-
from plugwise.exceptions import (
|
46
|
-
ConnectionFailedError,
|
47
|
-
InvalidAuthentication,
|
48
|
-
InvalidXMLError,
|
49
|
-
ResponseError,
|
50
|
-
)
|
51
45
|
from plugwise.util import (
|
52
46
|
check_model,
|
47
|
+
collect_power_values,
|
53
48
|
common_match_cases,
|
54
|
-
|
49
|
+
count_data_items,
|
55
50
|
format_measure,
|
56
51
|
skip_obsolete_measurements,
|
57
52
|
)
|
58
53
|
|
59
|
-
# This way of importing aiohttp is because of patch/mocking in testing (aiohttp timeouts)
|
60
|
-
from aiohttp import BasicAuth, ClientError, ClientResponse, ClientSession, ClientTimeout
|
61
|
-
|
62
54
|
# Time related
|
63
55
|
from dateutil import tz
|
64
56
|
from dateutil.parser import parse
|
@@ -67,144 +59,13 @@ from munch import Munch
|
|
67
59
|
from packaging import version
|
68
60
|
|
69
61
|
|
70
|
-
|
71
|
-
"""
|
72
|
-
|
73
|
-
|
74
|
-
|
75
|
-
host: str,
|
76
|
-
password: str,
|
77
|
-
port: int,
|
78
|
-
timeout: int,
|
79
|
-
username: str,
|
80
|
-
websession: ClientSession | None,
|
81
|
-
) -> None:
|
82
|
-
"""Set the constructor for this class."""
|
83
|
-
if not websession:
|
84
|
-
aio_timeout = ClientTimeout(total=timeout)
|
85
|
-
|
86
|
-
async def _create_session() -> ClientSession:
|
87
|
-
return ClientSession(timeout=aio_timeout) # pragma: no cover
|
88
|
-
|
89
|
-
loop = asyncio.get_event_loop()
|
90
|
-
if loop.is_running():
|
91
|
-
self._websession = ClientSession(timeout=aio_timeout)
|
92
|
-
else:
|
93
|
-
self._websession = loop.run_until_complete(
|
94
|
-
_create_session()
|
95
|
-
) # pragma: no cover
|
96
|
-
else:
|
97
|
-
self._websession = websession
|
62
|
+
def search_actuator_functionalities(appliance: etree, actuator: str) -> etree | None:
|
63
|
+
"""Helper-function for finding the relevant actuator xml-structure."""
|
64
|
+
locator = f"./actuator_functionalities/{actuator}"
|
65
|
+
if (search := appliance.find(locator)) is not None:
|
66
|
+
return search
|
98
67
|
|
99
|
-
|
100
|
-
if host.count(":") > 2: # pragma: no cover
|
101
|
-
host = f"[{host}]"
|
102
|
-
|
103
|
-
self._auth = BasicAuth(username, password=password)
|
104
|
-
self._endpoint = f"http://{host}:{str(port)}"
|
105
|
-
|
106
|
-
async def _request(
|
107
|
-
self,
|
108
|
-
command: str,
|
109
|
-
retry: int = 3,
|
110
|
-
method: str = "get",
|
111
|
-
data: str | None = None,
|
112
|
-
headers: dict[str, str] | None = None,
|
113
|
-
) -> etree:
|
114
|
-
"""Get/put/delete data from a give URL."""
|
115
|
-
resp: ClientResponse
|
116
|
-
url = f"{self._endpoint}{command}"
|
117
|
-
use_headers = headers
|
118
|
-
|
119
|
-
try:
|
120
|
-
match method:
|
121
|
-
case "delete":
|
122
|
-
resp = await self._websession.delete(url, auth=self._auth)
|
123
|
-
case "get":
|
124
|
-
# Work-around for Stretchv2, should not hurt the other smiles
|
125
|
-
use_headers = {"Accept-Encoding": "gzip"}
|
126
|
-
resp = await self._websession.get(
|
127
|
-
url, headers=use_headers, auth=self._auth
|
128
|
-
)
|
129
|
-
case "post":
|
130
|
-
use_headers = {"Content-type": "text/xml"}
|
131
|
-
resp = await self._websession.post(
|
132
|
-
url,
|
133
|
-
headers=use_headers,
|
134
|
-
data=data,
|
135
|
-
auth=self._auth,
|
136
|
-
)
|
137
|
-
case "put":
|
138
|
-
use_headers = {"Content-type": "text/xml"}
|
139
|
-
resp = await self._websession.put(
|
140
|
-
url,
|
141
|
-
headers=use_headers,
|
142
|
-
data=data,
|
143
|
-
auth=self._auth,
|
144
|
-
)
|
145
|
-
except (
|
146
|
-
ClientError
|
147
|
-
) as exc: # ClientError is an ancestor class of ServerTimeoutError
|
148
|
-
if retry < 1:
|
149
|
-
LOGGER.warning(
|
150
|
-
"Failed sending %s %s to Plugwise Smile, error: %s",
|
151
|
-
method,
|
152
|
-
command,
|
153
|
-
exc,
|
154
|
-
)
|
155
|
-
raise ConnectionFailedError from exc
|
156
|
-
return await self._request(command, retry - 1)
|
157
|
-
|
158
|
-
if resp.status == 504:
|
159
|
-
if retry < 1:
|
160
|
-
LOGGER.warning(
|
161
|
-
"Failed sending %s %s to Plugwise Smile, error: %s",
|
162
|
-
method,
|
163
|
-
command,
|
164
|
-
"504 Gateway Timeout",
|
165
|
-
)
|
166
|
-
raise ConnectionFailedError
|
167
|
-
return await self._request(command, retry - 1)
|
168
|
-
|
169
|
-
return await self._request_validate(resp, method)
|
170
|
-
|
171
|
-
async def _request_validate(self, resp: ClientResponse, method: str) -> etree:
|
172
|
-
"""Helper-function for _request(): validate the returned data."""
|
173
|
-
match resp.status:
|
174
|
-
case 200:
|
175
|
-
# Cornercases for server not responding with 202
|
176
|
-
if method in ("post", "put"):
|
177
|
-
return
|
178
|
-
case 202:
|
179
|
-
# Command accepted gives empty body with status 202
|
180
|
-
return
|
181
|
-
case 401:
|
182
|
-
msg = "Invalid Plugwise login, please retry with the correct credentials."
|
183
|
-
LOGGER.error("%s", msg)
|
184
|
-
raise InvalidAuthentication
|
185
|
-
case 405:
|
186
|
-
msg = "405 Method not allowed."
|
187
|
-
LOGGER.error("%s", msg)
|
188
|
-
raise ConnectionFailedError
|
189
|
-
|
190
|
-
if not (result := await resp.text()) or (
|
191
|
-
"<error>" in result and "Not started" not in result
|
192
|
-
):
|
193
|
-
LOGGER.warning("Smile response empty or error in %s", result)
|
194
|
-
raise ResponseError
|
195
|
-
|
196
|
-
try:
|
197
|
-
# Encode to ensure utf8 parsing
|
198
|
-
xml = etree.XML(escape_illegal_xml_characters(result).encode())
|
199
|
-
except etree.ParseError as exc:
|
200
|
-
LOGGER.warning("Smile returns invalid XML for %s", self._endpoint)
|
201
|
-
raise InvalidXMLError from exc
|
202
|
-
|
203
|
-
return xml
|
204
|
-
|
205
|
-
async def close_connection(self) -> None:
|
206
|
-
"""Close the Plugwise connection."""
|
207
|
-
await self._websession.close()
|
68
|
+
return None
|
208
69
|
|
209
70
|
|
210
71
|
class SmileHelper(SmileCommon):
|
@@ -212,61 +73,28 @@ class SmileHelper(SmileCommon):
|
|
212
73
|
|
213
74
|
def __init__(self) -> None:
|
214
75
|
"""Set the constructor for this class."""
|
215
|
-
self._cooling_present: bool
|
216
|
-
self._count: int
|
217
|
-
self._dhw_allowed_modes: list[str] = []
|
218
|
-
self._domain_objects: etree
|
219
76
|
self._endpoint: str
|
220
77
|
self._elga: bool
|
221
|
-
self._gw_allowed_modes: list[str] = []
|
222
|
-
self._heater_id: str
|
223
|
-
self._home_location: str
|
224
78
|
self._is_thermostat: bool
|
225
79
|
self._last_active: dict[str, str | None]
|
226
|
-
self._last_modified: dict[str, str] = {}
|
227
80
|
self._loc_data: dict[str, ThermoLoc]
|
228
|
-
self._notifications: dict[str, dict[str, str]] = {}
|
229
|
-
self._on_off_device: bool
|
230
|
-
self._opentherm_device: bool
|
231
|
-
self._reg_allowed_modes: list[str] = []
|
232
81
|
self._schedule_old_states: dict[str, dict[str, str]]
|
233
|
-
self.
|
234
|
-
self.
|
235
|
-
self.
|
236
|
-
self._thermo_locs: dict[str, ThermoLoc] = {}
|
237
|
-
###################################################################
|
238
|
-
# '_cooling_enabled' can refer to the state of the Elga heatpump
|
239
|
-
# connected to an Anna. For Elga, 'elga_status_code' in (8, 9)
|
240
|
-
# means cooling mode is available, next to heating mode.
|
241
|
-
# 'elga_status_code' = 8 means cooling is active, 9 means idle.
|
242
|
-
#
|
243
|
-
# '_cooling_enabled' cam refer to the state of the Loria or
|
244
|
-
# Thermastage heatpump connected to an Anna. For these,
|
245
|
-
# 'cooling_enabled' = on means set to cooling mode, instead of to
|
246
|
-
# heating mode.
|
247
|
-
# 'cooling_state' = on means cooling is active.
|
248
|
-
###################################################################
|
249
|
-
self._cooling_active = False
|
250
|
-
self._cooling_enabled = False
|
251
|
-
|
252
|
-
self.gateway_id: str
|
253
|
-
self.gw_data: GatewayData = {}
|
254
|
-
self.gw_entities: dict[str, GwEntityData] = {}
|
255
|
-
self.smile_fw_version: version.Version | None
|
82
|
+
self._gateway_id: str = NONE
|
83
|
+
self._zones: dict[str, GwEntityData]
|
84
|
+
self.gw_entities: dict[str, GwEntityData]
|
256
85
|
self.smile_hw_version: str | None
|
257
86
|
self.smile_mac_address: str | None
|
258
87
|
self.smile_model: str
|
259
88
|
self.smile_model_id: str | None
|
260
|
-
self.smile_name: str
|
261
|
-
self.smile_type: str
|
262
89
|
self.smile_version: version.Version | None
|
263
|
-
self.smile_zigbee_mac_address: str | None
|
264
|
-
self.therms_with_offset_func: list[str] = []
|
265
|
-
self._zones: dict[str, GwEntityData] = {}
|
266
90
|
SmileCommon.__init__(self)
|
267
91
|
|
268
92
|
def _all_appliances(self) -> None:
|
269
|
-
"""Collect all appliances with relevant info.
|
93
|
+
"""Collect all appliances with relevant info.
|
94
|
+
|
95
|
+
Also, collect the P1 smartmeter info from a location
|
96
|
+
as this one is not available as an appliance.
|
97
|
+
"""
|
270
98
|
self._count = 0
|
271
99
|
self._all_locations()
|
272
100
|
|
@@ -294,10 +122,10 @@ class SmileHelper(SmileCommon):
|
|
294
122
|
appl.location = None
|
295
123
|
if (appl_loc := appliance.find("location")) is not None:
|
296
124
|
appl.location = appl_loc.attrib["id"]
|
297
|
-
# Don't assign the
|
125
|
+
# Don't assign the _home_loc_id to thermostat-devices without a location,
|
298
126
|
# they are not active
|
299
127
|
elif appl.pwclass not in THERMOSTAT_CLASSES:
|
300
|
-
appl.location = self.
|
128
|
+
appl.location = self._home_loc_id
|
301
129
|
|
302
130
|
# Don't show orphaned thermostat-types
|
303
131
|
if appl.pwclass in THERMOSTAT_CLASSES and appl.location is None:
|
@@ -318,59 +146,31 @@ class SmileHelper(SmileCommon):
|
|
318
146
|
if not (appl := self._appliance_info_finder(appl, appliance)):
|
319
147
|
continue
|
320
148
|
|
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
149
|
self._create_gw_entities(appl)
|
327
150
|
|
328
|
-
# For P1 collect the connected SmartMeter info
|
329
151
|
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}
|
152
|
+
self._get_p1_smartmeter_info()
|
347
153
|
|
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
|
154
|
+
# Sort the gw_entities
|
155
|
+
self._sort_gw_entities()
|
357
156
|
|
358
|
-
|
157
|
+
def _get_p1_smartmeter_info(self) -> None:
|
158
|
+
"""For P1 collect the connected SmartMeter info from the Home/building location.
|
359
159
|
|
360
|
-
|
361
|
-
|
362
|
-
|
363
|
-
|
160
|
+
Note: For P1, the entity_id for the gateway and smartmeter are
|
161
|
+
switched to maintain backward compatibility with existing implementations.
|
162
|
+
"""
|
163
|
+
appl = Munch()
|
364
164
|
locator = MODULE_LOCATOR
|
365
|
-
module_data = self._get_module_data(
|
165
|
+
module_data = self._get_module_data(self._home_location, locator)
|
366
166
|
if not module_data["contents"]:
|
367
167
|
LOGGER.error("No module data found for SmartMeter") # pragma: no cover
|
368
|
-
return
|
369
|
-
|
370
|
-
appl.entity_id = self.
|
168
|
+
return # pragma: no cover
|
169
|
+
appl.available = None
|
170
|
+
appl.entity_id = self._gateway_id
|
371
171
|
appl.firmware = module_data["firmware_version"]
|
372
172
|
appl.hardware = module_data["hardware_version"]
|
373
|
-
appl.location =
|
173
|
+
appl.location = self._home_loc_id
|
374
174
|
appl.mac = None
|
375
175
|
appl.model = module_data["vendor_model"]
|
376
176
|
appl.model_id = None # don't use model_id for SmartMeter
|
@@ -379,21 +179,56 @@ class SmileHelper(SmileCommon):
|
|
379
179
|
appl.vendor_name = module_data["vendor_name"]
|
380
180
|
appl.zigbee_mac = None
|
381
181
|
|
182
|
+
# Replace the entity_id of the gateway by the smartmeter location_id
|
183
|
+
self.gw_entities[self._home_loc_id] = self.gw_entities.pop(self._gateway_id)
|
184
|
+
self._gateway_id = self._home_loc_id
|
185
|
+
|
382
186
|
self._create_gw_entities(appl)
|
383
187
|
|
188
|
+
def _sort_gw_entities(self) -> None:
|
189
|
+
"""Place the gateway and optional heater_central entities as 1st and 2nd."""
|
190
|
+
for dev_class in PRIORITY_DEVICE_CLASSES:
|
191
|
+
for entity_id, entity in dict(self.gw_entities).items():
|
192
|
+
if entity["dev_class"] == dev_class:
|
193
|
+
priority_entity = entity
|
194
|
+
self.gw_entities.pop(entity_id)
|
195
|
+
other_entities = self.gw_entities
|
196
|
+
priority_entities = {entity_id: priority_entity}
|
197
|
+
self.gw_entities = {**priority_entities, **other_entities}
|
198
|
+
|
199
|
+
def _all_locations(self) -> None:
|
200
|
+
"""Collect all locations."""
|
201
|
+
loc = Munch()
|
202
|
+
locations = self._domain_objects.findall("./location")
|
203
|
+
for location in locations:
|
204
|
+
loc.name = location.find("name").text
|
205
|
+
loc.loc_id = location.attrib["id"]
|
206
|
+
self._loc_data[loc.loc_id] = {"name": loc.name}
|
207
|
+
if loc.name != "Home":
|
208
|
+
continue
|
209
|
+
|
210
|
+
self._home_loc_id = loc.loc_id
|
211
|
+
self._home_location = self._domain_objects.find(
|
212
|
+
f"./location[@id='{loc.loc_id}']"
|
213
|
+
)
|
214
|
+
|
384
215
|
def _appliance_info_finder(self, appl: Munch, appliance: etree) -> Munch:
|
385
216
|
"""Collect info for all appliances found."""
|
386
217
|
match appl.pwclass:
|
387
218
|
case "gateway":
|
388
|
-
# Collect gateway
|
219
|
+
# Collect gateway entity info
|
389
220
|
return self._appl_gateway_info(appl, appliance)
|
390
221
|
case _ as dev_class if dev_class in THERMOSTAT_CLASSES:
|
391
|
-
# Collect thermostat
|
222
|
+
# Collect thermostat entity info
|
392
223
|
return self._appl_thermostat_info(appl, appliance)
|
393
224
|
case "heater_central":
|
394
|
-
# Collect heater_central
|
395
|
-
self._appl_heater_central_info(
|
396
|
-
|
225
|
+
# Collect heater_central entity info
|
226
|
+
self._appl_heater_central_info(
|
227
|
+
appl, appliance, False
|
228
|
+
) # False means non-legacy entity
|
229
|
+
self._dhw_allowed_modes = self._get_appl_actuator_modes(
|
230
|
+
appliance, "domestic_hot_water_mode_control_functionality"
|
231
|
+
)
|
397
232
|
# Skip orphaned heater_central (Core Issue #104433)
|
398
233
|
if appl.entity_id != self._heater_id:
|
399
234
|
return Munch()
|
@@ -419,8 +254,8 @@ class SmileHelper(SmileCommon):
|
|
419
254
|
|
420
255
|
def _appl_gateway_info(self, appl: Munch, appliance: etree) -> Munch:
|
421
256
|
"""Helper-function for _appliance_info_finder()."""
|
422
|
-
self.
|
423
|
-
appl.firmware = str(self.
|
257
|
+
self._gateway_id = appliance.attrib["id"]
|
258
|
+
appl.firmware = str(self.smile_version)
|
424
259
|
appl.hardware = self.smile_hw_version
|
425
260
|
appl.mac = self.smile_mac_address
|
426
261
|
appl.model = self.smile_model
|
@@ -430,11 +265,15 @@ class SmileHelper(SmileCommon):
|
|
430
265
|
|
431
266
|
# Adam: collect the ZigBee MAC address of the Smile
|
432
267
|
if self.smile(ADAM):
|
433
|
-
if (
|
268
|
+
if (
|
269
|
+
found := self._domain_objects.find(".//protocols/zig_bee_coordinator")
|
270
|
+
) is not None:
|
434
271
|
appl.zigbee_mac = found.find("mac_address").text
|
435
272
|
|
436
273
|
# Also, collect regulation_modes and check for cooling, indicating cooling-mode is present
|
437
|
-
self.
|
274
|
+
self._reg_allowed_modes = self._get_appl_actuator_modes(
|
275
|
+
appliance, "regulation_mode_control_functionality"
|
276
|
+
)
|
438
277
|
|
439
278
|
# Finally, collect the gateway_modes
|
440
279
|
self._gw_allowed_modes = []
|
@@ -445,32 +284,18 @@ class SmileHelper(SmileCommon):
|
|
445
284
|
|
446
285
|
return appl
|
447
286
|
|
448
|
-
def
|
449
|
-
|
450
|
-
|
451
|
-
|
452
|
-
|
453
|
-
|
454
|
-
|
455
|
-
|
456
|
-
|
457
|
-
|
458
|
-
self._reg_allowed_modes = reg_mode_list
|
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
|
287
|
+
def _get_appl_actuator_modes(
|
288
|
+
self, appliance: etree, actuator_type: str
|
289
|
+
) -> list[str]:
|
290
|
+
"""Get allowed modes for the given actuator type."""
|
291
|
+
mode_list: list[str] = []
|
292
|
+
if (
|
293
|
+
search := search_actuator_functionalities(appliance, actuator_type)
|
294
|
+
) is not None and (modes := search.find("allowed_modes")) is not None:
|
295
|
+
for mode in modes:
|
296
|
+
mode_list.append(mode.text)
|
472
297
|
|
473
|
-
return
|
298
|
+
return mode_list
|
474
299
|
|
475
300
|
def _get_appliances_with_offset_functionality(self) -> list[str]:
|
476
301
|
"""Helper-function collecting all appliance that have offset_functionality."""
|
@@ -510,7 +335,7 @@ class SmileHelper(SmileCommon):
|
|
510
335
|
# !! DON'T CHANGE below two if-lines, will break stuff !!
|
511
336
|
if self.smile_type == "power":
|
512
337
|
if entity["dev_class"] == "smartmeter":
|
513
|
-
|
338
|
+
data.update(self._power_data_from_location())
|
514
339
|
|
515
340
|
return data
|
516
341
|
|
@@ -552,21 +377,20 @@ class SmileHelper(SmileCommon):
|
|
552
377
|
|
553
378
|
return data
|
554
379
|
|
555
|
-
def _power_data_from_location(self
|
380
|
+
def _power_data_from_location(self) -> GwEntityData:
|
556
381
|
"""Helper-function for smile.py: _get_entity_data().
|
557
382
|
|
558
|
-
Collect the power-data
|
383
|
+
Collect the power-data from the Home location.
|
559
384
|
"""
|
560
385
|
data: GwEntityData = {"sensors": {}}
|
561
386
|
loc = Munch()
|
562
387
|
log_list: list[str] = ["point_log", "cumulative_log", "interval_log"]
|
563
388
|
t_string = "tariff"
|
564
389
|
|
565
|
-
|
566
|
-
loc.logs = search.find(f'./location[@id="{loc_id}"]/logs')
|
390
|
+
loc.logs = self._home_location.find("./logs")
|
567
391
|
for loc.measurement, loc.attrs in P1_MEASUREMENTS.items():
|
568
392
|
for loc.log_type in log_list:
|
569
|
-
|
393
|
+
collect_power_values(data, loc, t_string)
|
570
394
|
|
571
395
|
self._count += len(data["sensors"])
|
572
396
|
return data
|
@@ -602,7 +426,7 @@ class SmileHelper(SmileCommon):
|
|
602
426
|
appl_i_loc.text, ENERGY_WATT_HOUR
|
603
427
|
)
|
604
428
|
|
605
|
-
self.
|
429
|
+
self._count = count_data_items(self._count, data)
|
606
430
|
|
607
431
|
def _get_toggle_state(
|
608
432
|
self, xml: etree, toggle: str, name: ToggleNameType, data: GwEntityData
|
@@ -625,7 +449,7 @@ class SmileHelper(SmileCommon):
|
|
625
449
|
msg_id = notification.attrib["id"]
|
626
450
|
msg_type = notification.find("type").text
|
627
451
|
msg = notification.find("message").text
|
628
|
-
self._notifications
|
452
|
+
self._notifications[msg_id] = {msg_type: msg}
|
629
453
|
LOGGER.debug("Plugwise notifications: %s", self._notifications)
|
630
454
|
except AttributeError: # pragma: no cover
|
631
455
|
LOGGER.debug(
|
@@ -636,13 +460,19 @@ class SmileHelper(SmileCommon):
|
|
636
460
|
def _get_actuator_functionalities(
|
637
461
|
self, xml: etree, entity: GwEntityData, data: GwEntityData
|
638
462
|
) -> None:
|
639
|
-
"""
|
463
|
+
"""Get and process the actuator_functionalities details for an entity.
|
464
|
+
|
465
|
+
Add the resulting dict(s) to the entity's data.
|
466
|
+
"""
|
640
467
|
for item in ACTIVE_ACTUATORS:
|
641
468
|
# Skip max_dhw_temperature, not initially valid,
|
642
469
|
# skip thermostat for all but zones with thermostats
|
643
470
|
if item == "max_dhw_temperature" or (
|
644
|
-
item == "thermostat"
|
645
|
-
|
471
|
+
item == "thermostat"
|
472
|
+
and (
|
473
|
+
entity["dev_class"] != "climate"
|
474
|
+
if self.smile(ADAM)
|
475
|
+
else entity["dev_class"] != "thermostat"
|
646
476
|
)
|
647
477
|
):
|
648
478
|
continue
|
@@ -689,6 +519,21 @@ class SmileHelper(SmileCommon):
|
|
689
519
|
act_item = cast(ActuatorType, item)
|
690
520
|
data[act_item] = temp_dict
|
691
521
|
|
522
|
+
def _get_actuator_mode(
|
523
|
+
self, appliance: etree, entity_id: str, key: str
|
524
|
+
) -> str | None:
|
525
|
+
"""Helper-function for _get_regulation_mode and _get_gateway_mode.
|
526
|
+
|
527
|
+
Collect the requested gateway mode.
|
528
|
+
"""
|
529
|
+
if not (self.smile(ADAM) and entity_id == self._gateway_id):
|
530
|
+
return None
|
531
|
+
|
532
|
+
if (search := search_actuator_functionalities(appliance, key)) is not None:
|
533
|
+
return str(search.find("mode").text)
|
534
|
+
|
535
|
+
return None
|
536
|
+
|
692
537
|
def _get_regulation_mode(
|
693
538
|
self, appliance: etree, entity_id: str, data: GwEntityData
|
694
539
|
) -> None:
|
@@ -696,14 +541,14 @@ class SmileHelper(SmileCommon):
|
|
696
541
|
|
697
542
|
Adam: collect the gateway regulation_mode.
|
698
543
|
"""
|
699
|
-
if
|
700
|
-
|
701
|
-
|
702
|
-
|
703
|
-
|
704
|
-
data["select_regulation_mode"] =
|
544
|
+
if (
|
545
|
+
mode := self._get_actuator_mode(
|
546
|
+
appliance, entity_id, "regulation_mode_control_functionality"
|
547
|
+
)
|
548
|
+
) is not None:
|
549
|
+
data["select_regulation_mode"] = mode
|
705
550
|
self._count += 1
|
706
|
-
self._cooling_enabled =
|
551
|
+
self._cooling_enabled = mode == "cooling"
|
707
552
|
|
708
553
|
def _get_gateway_mode(
|
709
554
|
self, appliance: etree, entity_id: str, data: GwEntityData
|
@@ -712,40 +557,23 @@ class SmileHelper(SmileCommon):
|
|
712
557
|
|
713
558
|
Adam: collect the gateway mode.
|
714
559
|
"""
|
715
|
-
if
|
716
|
-
|
717
|
-
|
718
|
-
|
719
|
-
|
720
|
-
data["select_gateway_mode"] =
|
560
|
+
if (
|
561
|
+
mode := self._get_actuator_mode(
|
562
|
+
appliance, entity_id, "gateway_mode_control_functionality"
|
563
|
+
)
|
564
|
+
) is not None:
|
565
|
+
data["select_gateway_mode"] = mode
|
721
566
|
self._count += 1
|
722
567
|
|
723
568
|
def _get_gateway_outdoor_temp(self, entity_id: str, data: GwEntityData) -> None:
|
724
|
-
"""Adam & Anna: the Smile outdoor_temperature is present in
|
725
|
-
|
726
|
-
|
727
|
-
|
728
|
-
|
729
|
-
|
730
|
-
self._home_location, "outdoor_temperature"
|
731
|
-
)
|
732
|
-
if outdoor_temperature is not None:
|
733
|
-
data.update({"sensors": {"outdoor_temperature": outdoor_temperature}})
|
569
|
+
"""Adam & Anna: the Smile outdoor_temperature is present in the Home location."""
|
570
|
+
if self._is_thermostat and entity_id == self._gateway_id:
|
571
|
+
locator = "./logs/point_log[type='outdoor_temperature']/period/measurement"
|
572
|
+
if (found := self._home_location.find(locator)) is not None:
|
573
|
+
value = format_measure(found.text, NONE)
|
574
|
+
data.update({"sensors": {"outdoor_temperature": value}})
|
734
575
|
self._count += 1
|
735
576
|
|
736
|
-
def _object_value(self, obj_id: str, measurement: str) -> float | int | None:
|
737
|
-
"""Helper-function for smile.py: _get_entity_data().
|
738
|
-
|
739
|
-
Obtain the value/state for the given object from a location in DOMAIN_OBJECTS
|
740
|
-
"""
|
741
|
-
val: float | int | None = None
|
742
|
-
search = self._domain_objects
|
743
|
-
locator = f'./location[@id="{obj_id}"]/logs/point_log[type="{measurement}"]/period/measurement'
|
744
|
-
if (found := search.find(locator)) is not None:
|
745
|
-
val = format_measure(found.text, NONE)
|
746
|
-
|
747
|
-
return val
|
748
|
-
|
749
577
|
def _process_c_heating_state(self, data: GwEntityData) -> None:
|
750
578
|
"""Helper-function for _get_measurement_data().
|
751
579
|
|
@@ -768,19 +596,23 @@ class SmileHelper(SmileCommon):
|
|
768
596
|
data["binary_sensors"]["heating_state"] = data["c_heating_state"]
|
769
597
|
|
770
598
|
if self.smile(ADAM):
|
599
|
+
# First count when not present, then create and init to False.
|
600
|
+
# When present init to False
|
771
601
|
if "heating_state" not in data["binary_sensors"]:
|
772
602
|
self._count += 1
|
773
603
|
data["binary_sensors"]["heating_state"] = False
|
604
|
+
|
774
605
|
if "cooling_state" not in data["binary_sensors"]:
|
775
606
|
self._count += 1
|
776
607
|
data["binary_sensors"]["cooling_state"] = False
|
608
|
+
|
777
609
|
if self._cooling_enabled:
|
778
610
|
data["binary_sensors"]["cooling_state"] = data["c_heating_state"]
|
779
611
|
else:
|
780
612
|
data["binary_sensors"]["heating_state"] = data["c_heating_state"]
|
781
613
|
|
782
614
|
def _update_anna_cooling(self, entity_id: str, data: GwEntityData) -> None:
|
783
|
-
"""Update the Anna heater_central
|
615
|
+
"""Update the Anna heater_central entity for cooling.
|
784
616
|
|
785
617
|
Support added for Techneco Elga and Thercon Loria/Thermastage.
|
786
618
|
"""
|
@@ -793,14 +625,20 @@ class SmileHelper(SmileCommon):
|
|
793
625
|
self._update_loria_cooling(data)
|
794
626
|
|
795
627
|
def _update_elga_cooling(self, data: GwEntityData) -> None:
|
796
|
-
"""# Anna+Elga: base cooling_state on the elga-status-code.
|
628
|
+
"""# Anna+Elga: base cooling_state on the elga-status-code.
|
629
|
+
|
630
|
+
For Elga, 'elga_status_code' in (8, 9) means cooling is enabled.
|
631
|
+
'elga_status_code' = 8 means cooling is active, 9 means idle.
|
632
|
+
"""
|
797
633
|
if data["thermostat_supports_cooling"]:
|
798
634
|
# Techneco Elga has cooling-capability
|
799
635
|
self._cooling_present = True
|
800
636
|
data["model"] = "Generic heater/cooler"
|
801
637
|
# Cooling_enabled in xml does NOT show the correct status!
|
802
638
|
# Setting it specifically:
|
803
|
-
self._cooling_enabled = data["binary_sensors"]["cooling_enabled"] = data[
|
639
|
+
self._cooling_enabled = data["binary_sensors"]["cooling_enabled"] = data[
|
640
|
+
"elga_status_code"
|
641
|
+
] in (8, 9)
|
804
642
|
data["binary_sensors"]["cooling_state"] = self._cooling_active = (
|
805
643
|
data["elga_status_code"] == 8
|
806
644
|
)
|
@@ -813,14 +651,22 @@ class SmileHelper(SmileCommon):
|
|
813
651
|
self._count -= 1
|
814
652
|
|
815
653
|
def _update_loria_cooling(self, data: GwEntityData) -> None:
|
816
|
-
"""Loria/Thermastage: base cooling-related on cooling_state and modulation_level.
|
654
|
+
"""Loria/Thermastage: base cooling-related on cooling_state and modulation_level.
|
655
|
+
|
656
|
+
For the Loria or Thermastage heatpump connected to an Anna cooling-enabled is
|
657
|
+
indicated via the Cooling Enable switch in the Plugwise App.
|
658
|
+
"""
|
817
659
|
# For Loria/Thermastage it's not clear if cooling_enabled in xml shows the correct status,
|
818
660
|
# setting it specifically:
|
819
|
-
self._cooling_enabled = data["binary_sensors"]["cooling_enabled"] = data[
|
661
|
+
self._cooling_enabled = data["binary_sensors"]["cooling_enabled"] = data[
|
662
|
+
"binary_sensors"
|
663
|
+
]["cooling_state"]
|
820
664
|
self._cooling_active = data["sensors"]["modulation_level"] == 100
|
821
665
|
# For Loria the above does not work (pw-beta issue #301)
|
822
666
|
if "cooling_ena_switch" in data["switches"]:
|
823
|
-
self._cooling_enabled = data["binary_sensors"]["cooling_enabled"] = data[
|
667
|
+
self._cooling_enabled = data["binary_sensors"]["cooling_enabled"] = data[
|
668
|
+
"switches"
|
669
|
+
]["cooling_ena_switch"]
|
824
670
|
self._cooling_active = data["binary_sensors"]["cooling_state"]
|
825
671
|
|
826
672
|
def _cleanup_data(self, data: GwEntityData) -> None:
|
@@ -870,7 +716,10 @@ class SmileHelper(SmileCommon):
|
|
870
716
|
"dev_class": "climate",
|
871
717
|
"model": "ThermoZone",
|
872
718
|
"name": loc_data["name"],
|
873
|
-
"thermostats": {
|
719
|
+
"thermostats": {
|
720
|
+
"primary": loc_data["primary"],
|
721
|
+
"secondary": loc_data["secondary"],
|
722
|
+
},
|
874
723
|
"vendor": "Plugwise",
|
875
724
|
}
|
876
725
|
self._count += 3
|
@@ -910,10 +759,12 @@ class SmileHelper(SmileCommon):
|
|
910
759
|
if thermo_matching[appl_class] == thermo_loc["primary_prio"]:
|
911
760
|
thermo_loc["primary"].append(appliance_id)
|
912
761
|
# Pre-elect new primary
|
913
|
-
elif (thermo_rank := thermo_matching[appl_class]) > thermo_loc[
|
762
|
+
elif (thermo_rank := thermo_matching[appl_class]) > thermo_loc[
|
763
|
+
"primary_prio"
|
764
|
+
]:
|
914
765
|
thermo_loc["primary_prio"] = thermo_rank
|
915
766
|
# Demote former primary
|
916
|
-
if
|
767
|
+
if tl_primary := thermo_loc["primary"]:
|
917
768
|
thermo_loc["secondary"] += tl_primary
|
918
769
|
thermo_loc["primary"] = []
|
919
770
|
|
@@ -929,11 +780,9 @@ class SmileHelper(SmileCommon):
|
|
929
780
|
Represents the heating/cooling demand-state of the local primary thermostat.
|
930
781
|
Note: heating or cooling can still be active when the setpoint has been reached.
|
931
782
|
"""
|
932
|
-
locator = f'location[@id="{loc_id}"]'
|
933
|
-
if (
|
934
|
-
|
935
|
-
if (ctrl_state := location.find(locator)) is not None:
|
936
|
-
return str(ctrl_state.text)
|
783
|
+
locator = f'location[@id="{loc_id}"]/actuator_functionalities/thermostat_functionality[type="thermostat"]/control_state'
|
784
|
+
if (ctrl_state := self._domain_objects.find(locator)) is not None:
|
785
|
+
return str(ctrl_state.text)
|
937
786
|
|
938
787
|
# Handle missing control_state in regulation_mode off for firmware >= 3.2.0 (issue #776)
|
939
788
|
# In newer firmware versions, default to "off" when control_state is not present
|
@@ -1010,9 +859,17 @@ class SmileHelper(SmileCommon):
|
|
1010
859
|
for rule in self._domain_objects.findall(f'./rule[name="{name}"]'):
|
1011
860
|
active = rule.find("active").text
|
1012
861
|
if rule.find(locator) is not None:
|
1013
|
-
schedule_ids[rule.attrib["id"]] = {
|
862
|
+
schedule_ids[rule.attrib["id"]] = {
|
863
|
+
"location": loc_id,
|
864
|
+
"name": name,
|
865
|
+
"active": active,
|
866
|
+
}
|
1014
867
|
else:
|
1015
|
-
schedule_ids[rule.attrib["id"]] = {
|
868
|
+
schedule_ids[rule.attrib["id"]] = {
|
869
|
+
"location": NONE,
|
870
|
+
"name": name,
|
871
|
+
"active": active,
|
872
|
+
}
|
1016
873
|
|
1017
874
|
return schedule_ids
|
1018
875
|
|
@@ -1029,9 +886,17 @@ class SmileHelper(SmileCommon):
|
|
1029
886
|
name = rule.find("name").text
|
1030
887
|
active = rule.find("active").text
|
1031
888
|
if rule.find(locator2) is not None:
|
1032
|
-
schedule_ids[rule.attrib["id"]] = {
|
889
|
+
schedule_ids[rule.attrib["id"]] = {
|
890
|
+
"location": loc_id,
|
891
|
+
"name": name,
|
892
|
+
"active": active,
|
893
|
+
}
|
1033
894
|
else:
|
1034
|
-
schedule_ids[rule.attrib["id"]] = {
|
895
|
+
schedule_ids[rule.attrib["id"]] = {
|
896
|
+
"location": NONE,
|
897
|
+
"name": name,
|
898
|
+
"active": active,
|
899
|
+
}
|
1035
900
|
|
1036
901
|
return schedule_ids
|
1037
902
|
|