plugwise 0.36.3__py3-none-any.whl → 0.37.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- plugwise/__init__.py +187 -713
- plugwise/data.py +263 -0
- plugwise/helper.py +122 -367
- plugwise/legacy/data.py +124 -0
- plugwise/legacy/helper.py +775 -0
- plugwise/legacy/smile.py +272 -0
- plugwise/smile.py +426 -0
- plugwise/util.py +35 -1
- {plugwise-0.36.3.dist-info → plugwise-0.37.0.dist-info}/METADATA +1 -1
- plugwise-0.37.0.dist-info/RECORD +16 -0
- plugwise-0.36.3.dist-info/RECORD +0 -11
- {plugwise-0.36.3.dist-info → plugwise-0.37.0.dist-info}/LICENSE +0 -0
- {plugwise-0.36.3.dist-info → plugwise-0.37.0.dist-info}/WHEEL +0 -0
- {plugwise-0.36.3.dist-info → plugwise-0.37.0.dist-info}/top_level.txt +0 -0
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
|
-
|
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
|
-
|
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
|
77
|
-
|
78
|
-
|
79
|
-
|
80
|
-
|
81
|
-
|
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
|
-
|
97
|
-
|
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
|
-
|
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
|
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
|
-
|
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
|
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.
|
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
|
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
|
247
|
-
self._opentherm_device
|
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
|
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.
|
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.
|
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
|
372
|
-
locator = "./
|
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.
|
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
|
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.
|
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.
|
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.
|
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
|
-
|
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
|
-
#
|
626
|
-
#
|
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
|
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
|
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
|
567
|
+
# For P1 collect the connected SmartMeter info
|
681
568
|
if self.smile_type == "power":
|
682
569
|
self._p1_smartmeter_info_finder(appl)
|
683
570
|
# P1: for gateway and smartmeter switch device_id - part 2
|
@@ -697,22 +584,6 @@ class SmileHelper:
|
|
697
584
|
add_to_front = {dev_id: tmp_device}
|
698
585
|
self.gw_devices = {**add_to_front, **cleared_dict}
|
699
586
|
|
700
|
-
def _match_locations(self) -> dict[str, ThermoLoc]:
|
701
|
-
"""Helper-function for _scan_thermostats().
|
702
|
-
|
703
|
-
Match appliances with locations.
|
704
|
-
"""
|
705
|
-
matched_locations: dict[str, ThermoLoc] = {}
|
706
|
-
for location_id, location_details in self._loc_data.items():
|
707
|
-
for appliance_details in self.gw_devices.values():
|
708
|
-
if appliance_details["location"] == location_id:
|
709
|
-
location_details.update(
|
710
|
-
{"master": None, "master_prio": 0, "slaves": set()}
|
711
|
-
)
|
712
|
-
matched_locations[location_id] = location_details
|
713
|
-
|
714
|
-
return matched_locations
|
715
|
-
|
716
587
|
def _control_state(self, loc_id: str) -> str | bool:
|
717
588
|
"""Helper-function for _device_data_adam().
|
718
589
|
|
@@ -728,30 +599,12 @@ class SmileHelper:
|
|
728
599
|
|
729
600
|
return False
|
730
601
|
|
731
|
-
def _presets_legacy(self) -> dict[str, list[float]]:
|
732
|
-
"""Helper-function for presets() - collect Presets for a legacy Anna."""
|
733
|
-
presets: dict[str, list[float]] = {}
|
734
|
-
for directive in self._domain_objects.findall("rule/directives/when/then"):
|
735
|
-
if directive is not None and directive.get("icon") is not None:
|
736
|
-
# Ensure list of heating_setpoint, cooling_setpoint
|
737
|
-
presets[directive.attrib["icon"]] = [
|
738
|
-
float(directive.attrib["temperature"]),
|
739
|
-
0,
|
740
|
-
]
|
741
|
-
|
742
|
-
return presets
|
743
|
-
|
744
602
|
def _presets(self, loc_id: str) -> dict[str, list[float]]:
|
745
603
|
"""Collect Presets for a Thermostat based on location_id."""
|
746
604
|
presets: dict[str, list[float]] = {}
|
747
605
|
tag_1 = "zone_setpoint_and_state_based_on_preset"
|
748
606
|
tag_2 = "Thermostat presets"
|
749
|
-
|
750
|
-
if self._smile_legacy:
|
751
|
-
return self._presets_legacy()
|
752
|
-
|
753
607
|
if not (rule_ids := self._rule_ids_by_tag(tag_1, loc_id)):
|
754
|
-
rule_ids = None
|
755
608
|
if not (rule_ids := self._rule_ids_by_name(tag_2, loc_id)):
|
756
609
|
return presets # pragma: no cover
|
757
610
|
|
@@ -813,9 +666,6 @@ class SmileHelper:
|
|
813
666
|
for measurement, attrs in measurements.items():
|
814
667
|
p_locator = f'.//logs/point_log[type="{measurement}"]/period/measurement'
|
815
668
|
if (appl_p_loc := appliance.find(p_locator)) is not None:
|
816
|
-
if self._smile_legacy and measurement == "domestic_hot_water_state":
|
817
|
-
continue
|
818
|
-
|
819
669
|
# Skip known obsolete measurements
|
820
670
|
updated_date_locator = (
|
821
671
|
f'.//logs/point_log[type="{measurement}"]/updated_date'
|
@@ -909,7 +759,7 @@ class SmileHelper:
|
|
909
759
|
def _get_appliances_with_offset_functionality(self) -> list[str]:
|
910
760
|
"""Helper-function collecting all appliance that have offset_functionality."""
|
911
761
|
therm_list: list[str] = []
|
912
|
-
offset_appls = self.
|
762
|
+
offset_appls = self._domain_objects.findall(
|
913
763
|
'.//actuator_functionalities/offset_functionality[type="temperature_offset"]/offset/../../..'
|
914
764
|
)
|
915
765
|
for item in offset_appls:
|
@@ -933,10 +783,6 @@ class SmileHelper:
|
|
933
783
|
functionality = "thermostat_functionality"
|
934
784
|
if item == "temperature_offset":
|
935
785
|
functionality = "offset_functionality"
|
936
|
-
# Don't support temperature_offset for legacy Anna
|
937
|
-
if self._smile_legacy:
|
938
|
-
continue
|
939
|
-
|
940
786
|
# When there is no updated_date-text, skip the actuator
|
941
787
|
updated_date_location = f'.//actuator_functionalities/{functionality}[type="{item}"]/updated_date'
|
942
788
|
if (
|
@@ -1052,19 +898,16 @@ class SmileHelper:
|
|
1052
898
|
Collect the appliance-data based on device id.
|
1053
899
|
"""
|
1054
900
|
data: DeviceData = {"binary_sensors": {}, "sensors": {}, "switches": {}}
|
1055
|
-
# Get P1 smartmeter data from LOCATIONS
|
901
|
+
# Get P1 smartmeter data from LOCATIONS
|
1056
902
|
device = self.gw_devices[dev_id]
|
1057
903
|
# !! DON'T CHANGE below two if-lines, will break stuff !!
|
1058
904
|
if self.smile_type == "power":
|
1059
905
|
if device["dev_class"] == "smartmeter":
|
1060
|
-
|
1061
|
-
data.update(self._power_data_from_location(device["location"]))
|
1062
|
-
else:
|
1063
|
-
data.update(self._power_data_from_modules())
|
906
|
+
data.update(self._power_data_from_location(device["location"]))
|
1064
907
|
|
1065
908
|
return data
|
1066
909
|
|
1067
|
-
# Get non-
|
910
|
+
# Get non-P1 data from APPLIANCES
|
1068
911
|
measurements = DEVICE_MEASUREMENTS
|
1069
912
|
if self._is_thermostat and dev_id == self._heater_id:
|
1070
913
|
measurements = HEATER_CENTRAL_MEASUREMENTS
|
@@ -1074,7 +917,7 @@ class SmileHelper:
|
|
1074
917
|
# Counting of this item is done in _appliance_measurements()
|
1075
918
|
|
1076
919
|
if (
|
1077
|
-
appliance := self.
|
920
|
+
appliance := self._domain_objects.find(f'./appliance[@id="{dev_id}"]')
|
1078
921
|
) is not None:
|
1079
922
|
self._appliance_measurements(appliance, data, measurements)
|
1080
923
|
self._get_lock_state(appliance, data)
|
@@ -1141,33 +984,6 @@ class SmileHelper:
|
|
1141
984
|
|
1142
985
|
return data
|
1143
986
|
|
1144
|
-
def _rank_thermostat(
|
1145
|
-
self,
|
1146
|
-
thermo_matching: dict[str, int],
|
1147
|
-
loc_id: str,
|
1148
|
-
appliance_id: str,
|
1149
|
-
appliance_details: DeviceData,
|
1150
|
-
) -> None:
|
1151
|
-
"""Helper-function for _scan_thermostats().
|
1152
|
-
|
1153
|
-
Rank the thermostat based on appliance_details: master or slave.
|
1154
|
-
"""
|
1155
|
-
appl_class = appliance_details["dev_class"]
|
1156
|
-
appl_d_loc = appliance_details["location"]
|
1157
|
-
if loc_id == appl_d_loc and appl_class in thermo_matching:
|
1158
|
-
# Pre-elect new master
|
1159
|
-
if thermo_matching[appl_class] > self._thermo_locs[loc_id]["master_prio"]:
|
1160
|
-
# Demote former master
|
1161
|
-
if (tl_master := self._thermo_locs[loc_id]["master"]) is not None:
|
1162
|
-
self._thermo_locs[loc_id]["slaves"].add(tl_master)
|
1163
|
-
|
1164
|
-
# Crown master
|
1165
|
-
self._thermo_locs[loc_id]["master_prio"] = thermo_matching[appl_class]
|
1166
|
-
self._thermo_locs[loc_id]["master"] = appliance_id
|
1167
|
-
|
1168
|
-
else:
|
1169
|
-
self._thermo_locs[loc_id]["slaves"].add(appliance_id)
|
1170
|
-
|
1171
987
|
def _scan_thermostats(self) -> None:
|
1172
988
|
"""Helper-function for smile.py: get_all_devices().
|
1173
989
|
|
@@ -1194,26 +1010,56 @@ class SmileHelper:
|
|
1194
1010
|
if "slaves" in tl_loc_id and dev_id in tl_loc_id["slaves"]:
|
1195
1011
|
device["dev_class"] = "thermo_sensor"
|
1196
1012
|
|
1197
|
-
def
|
1198
|
-
"""Helper-function for
|
1013
|
+
def _match_locations(self) -> dict[str, ThermoLoc]:
|
1014
|
+
"""Helper-function for _scan_thermostats().
|
1199
1015
|
|
1200
|
-
|
1016
|
+
Match appliances with locations.
|
1201
1017
|
"""
|
1202
|
-
|
1203
|
-
|
1018
|
+
matched_locations: dict[str, ThermoLoc] = {}
|
1019
|
+
for location_id, location_details in self.loc_data.items():
|
1020
|
+
for appliance_details in self.gw_devices.values():
|
1021
|
+
if appliance_details["location"] == location_id:
|
1022
|
+
location_details.update(
|
1023
|
+
{"master": None, "master_prio": 0, "slaves": set()}
|
1024
|
+
)
|
1025
|
+
matched_locations[location_id] = location_details
|
1026
|
+
|
1027
|
+
return matched_locations
|
1204
1028
|
|
1205
|
-
|
1029
|
+
def _rank_thermostat(
|
1030
|
+
self,
|
1031
|
+
thermo_matching: dict[str, int],
|
1032
|
+
loc_id: str,
|
1033
|
+
appliance_id: str,
|
1034
|
+
appliance_details: DeviceData,
|
1035
|
+
) -> None:
|
1036
|
+
"""Helper-function for _scan_thermostats().
|
1037
|
+
|
1038
|
+
Rank the thermostat based on appliance_details: master or slave.
|
1039
|
+
"""
|
1040
|
+
appl_class = appliance_details["dev_class"]
|
1041
|
+
appl_d_loc = appliance_details["location"]
|
1042
|
+
if loc_id == appl_d_loc and appl_class in thermo_matching:
|
1043
|
+
# Pre-elect new master
|
1044
|
+
if thermo_matching[appl_class] > self._thermo_locs[loc_id]["master_prio"]:
|
1045
|
+
# Demote former master
|
1046
|
+
if (tl_master := self._thermo_locs[loc_id]["master"]) is not None:
|
1047
|
+
self._thermo_locs[loc_id]["slaves"].add(tl_master)
|
1048
|
+
|
1049
|
+
# Crown master
|
1050
|
+
self._thermo_locs[loc_id]["master_prio"] = thermo_matching[appl_class]
|
1051
|
+
self._thermo_locs[loc_id]["master"] = appliance_id
|
1052
|
+
|
1053
|
+
else:
|
1054
|
+
self._thermo_locs[loc_id]["slaves"].add(appliance_id)
|
1206
1055
|
|
1207
1056
|
def _thermostat_uri(self, loc_id: str) -> str:
|
1208
1057
|
"""Helper-function for smile.py: set_temperature().
|
1209
1058
|
|
1210
1059
|
Determine the location-set_temperature uri - from LOCATIONS.
|
1211
1060
|
"""
|
1212
|
-
if self._smile_legacy:
|
1213
|
-
return self._thermostat_uri_legacy()
|
1214
|
-
|
1215
1061
|
locator = f'./location[@id="{loc_id}"]/actuator_functionalities/thermostat_functionality'
|
1216
|
-
thermostat_functionality_id = self.
|
1062
|
+
thermostat_functionality_id = self._domain_objects.find(locator).attrib["id"]
|
1217
1063
|
|
1218
1064
|
return f"{LOCATIONS};id={loc_id}/thermostat;id={thermostat_functionality_id}"
|
1219
1065
|
|
@@ -1233,8 +1079,8 @@ class SmileHelper:
|
|
1233
1079
|
group_name = group.find("name").text
|
1234
1080
|
group_type = group.find("type").text
|
1235
1081
|
group_appliances = group.findall("appliances/appliance")
|
1082
|
+
# Check if members are not orphaned
|
1236
1083
|
for item in group_appliances:
|
1237
|
-
# Check if members are not orphaned - stretch
|
1238
1084
|
if item.attrib["id"] in self.gw_devices:
|
1239
1085
|
members.append(item.attrib["id"])
|
1240
1086
|
|
@@ -1261,7 +1107,7 @@ class SmileHelper:
|
|
1261
1107
|
"""
|
1262
1108
|
loc_found: int = 0
|
1263
1109
|
open_valve_count: int = 0
|
1264
|
-
for appliance in self.
|
1110
|
+
for appliance in self._domain_objects.findall("./appliance"):
|
1265
1111
|
locator = './logs/point_log[type="valve_position"]/period/measurement'
|
1266
1112
|
if (appl_loc := appliance.find(locator)) is not None:
|
1267
1113
|
loc_found += 1
|
@@ -1305,7 +1151,6 @@ class SmileHelper:
|
|
1305
1151
|
"""Helper-function for _power_data_from_location() and _power_data_from_modules()."""
|
1306
1152
|
loc.found = True
|
1307
1153
|
# If locator not found look for P1 gas_consumed or phase data (without tariff)
|
1308
|
-
# or for P1 legacy electricity_point_meter or gas_*_meter data
|
1309
1154
|
if loc.logs.find(loc.locator) is None:
|
1310
1155
|
if "log" in loc.log_type and (
|
1311
1156
|
"gas" in loc.measurement or "phase" in loc.measurement
|
@@ -1321,25 +1166,9 @@ class SmileHelper:
|
|
1321
1166
|
if loc.logs.find(loc.locator) is None:
|
1322
1167
|
loc.found = False
|
1323
1168
|
return loc
|
1324
|
-
# P1 legacy point_meter has no tariff_indicator
|
1325
|
-
elif "meter" in loc.log_type and (
|
1326
|
-
"point" in loc.log_type or "gas" in loc.measurement
|
1327
|
-
):
|
1328
|
-
# Avoid double processing by skipping one peak-list option
|
1329
|
-
if loc.peak_select == "nl_offpeak":
|
1330
|
-
loc.found = False
|
1331
|
-
return loc
|
1332
|
-
|
1333
|
-
loc.locator = (
|
1334
|
-
f"./{loc.meas_list[0]}_{loc.log_type}/"
|
1335
|
-
f'measurement[@directionality="{loc.meas_list[1]}"]'
|
1336
|
-
)
|
1337
|
-
if loc.logs.find(loc.locator) is None:
|
1338
|
-
loc.found = False
|
1339
|
-
return loc
|
1340
1169
|
else:
|
1341
|
-
loc.found = False
|
1342
|
-
return loc
|
1170
|
+
loc.found = False # pragma: no cover
|
1171
|
+
return loc # pragma: no cover
|
1343
1172
|
|
1344
1173
|
if (peak := loc.peak_select.split("_")[1]) == "offpeak":
|
1345
1174
|
peak = "off_peak"
|
@@ -1366,7 +1195,7 @@ class SmileHelper:
|
|
1366
1195
|
peak_list: list[str] = ["nl_peak", "nl_offpeak"]
|
1367
1196
|
t_string = "tariff"
|
1368
1197
|
|
1369
|
-
search = self.
|
1198
|
+
search = self._domain_objects
|
1370
1199
|
loc.logs = search.find(f'./location[@id="{loc_id}"]/logs')
|
1371
1200
|
for loc.measurement, loc.attrs in P1_MEASUREMENTS.items():
|
1372
1201
|
for loc.log_type in log_list:
|
@@ -1388,92 +1217,16 @@ class SmileHelper:
|
|
1388
1217
|
self._count += len(direct_data["sensors"])
|
1389
1218
|
return direct_data
|
1390
1219
|
|
1391
|
-
def _power_data_from_modules(self) -> DeviceData:
|
1392
|
-
"""Helper-function for smile.py: _get_device_data().
|
1393
|
-
|
1394
|
-
Collect the power-data from MODULES (P1 legacy only).
|
1395
|
-
"""
|
1396
|
-
direct_data: DeviceData = {"sensors": {}}
|
1397
|
-
loc = Munch()
|
1398
|
-
mod_list: list[str] = ["interval_meter", "cumulative_meter", "point_meter"]
|
1399
|
-
peak_list: list[str] = ["nl_peak", "nl_offpeak"]
|
1400
|
-
t_string = "tariff_indicator"
|
1401
|
-
|
1402
|
-
search = self._modules
|
1403
|
-
mod_logs = search.findall("./module/services")
|
1404
|
-
for loc.measurement, loc.attrs in P1_LEGACY_MEASUREMENTS.items():
|
1405
|
-
loc.meas_list = loc.measurement.split("_")
|
1406
|
-
for loc.logs in mod_logs:
|
1407
|
-
for loc.log_type in mod_list:
|
1408
|
-
for loc.peak_select in peak_list:
|
1409
|
-
loc.locator = (
|
1410
|
-
f"./{loc.meas_list[0]}_{loc.log_type}/measurement"
|
1411
|
-
f'[@directionality="{loc.meas_list[1]}"][@{t_string}="{loc.peak_select}"]'
|
1412
|
-
)
|
1413
|
-
loc = self._power_data_peak_value(loc)
|
1414
|
-
if not loc.found:
|
1415
|
-
continue
|
1416
|
-
|
1417
|
-
direct_data = self.power_data_energy_diff(
|
1418
|
-
loc.measurement, loc.net_string, loc.f_val, direct_data
|
1419
|
-
)
|
1420
|
-
key = cast(SensorType, loc.key_string)
|
1421
|
-
direct_data["sensors"][key] = loc.f_val
|
1422
|
-
|
1423
|
-
self._count += len(direct_data["sensors"])
|
1424
|
-
return direct_data
|
1425
|
-
|
1426
1220
|
def _preset(self, loc_id: str) -> str | None:
|
1427
1221
|
"""Helper-function for smile.py: device_data_climate().
|
1428
1222
|
|
1429
1223
|
Collect the active preset based on Location ID.
|
1430
1224
|
"""
|
1431
|
-
|
1432
|
-
|
1433
|
-
|
1434
|
-
return str(preset.text)
|
1435
|
-
return None
|
1225
|
+
locator = f'./location[@id="{loc_id}"]/preset'
|
1226
|
+
if (preset := self._domain_objects.find(locator)) is not None:
|
1227
|
+
return str(preset.text)
|
1436
1228
|
|
1437
|
-
|
1438
|
-
if (
|
1439
|
-
not (active_rule := etree_to_dict(self._domain_objects.find(locator)))
|
1440
|
-
or "icon" not in active_rule
|
1441
|
-
):
|
1442
|
-
return None
|
1443
|
-
|
1444
|
-
return active_rule["icon"]
|
1445
|
-
|
1446
|
-
def _schedules_legacy(
|
1447
|
-
self,
|
1448
|
-
avail: list[str],
|
1449
|
-
location: str,
|
1450
|
-
sel: str,
|
1451
|
-
) -> tuple[list[str], str]:
|
1452
|
-
"""Helper-function for _schedules().
|
1453
|
-
|
1454
|
-
Collect available schedules/schedules for the legacy thermostat.
|
1455
|
-
"""
|
1456
|
-
name: str | None = None
|
1457
|
-
|
1458
|
-
search = self._domain_objects
|
1459
|
-
for schedule in search.findall("./rule"):
|
1460
|
-
if rule_name := schedule.find("name").text:
|
1461
|
-
if "preset" not in rule_name:
|
1462
|
-
name = rule_name
|
1463
|
-
|
1464
|
-
log_type = "schedule_state"
|
1465
|
-
locator = f"./appliance[type='thermostat']/logs/point_log[type='{log_type}']/period/measurement"
|
1466
|
-
active = False
|
1467
|
-
if (result := search.find(locator)) is not None:
|
1468
|
-
active = result.text == "on"
|
1469
|
-
|
1470
|
-
if name is not None:
|
1471
|
-
avail = [name]
|
1472
|
-
if active:
|
1473
|
-
sel = name
|
1474
|
-
|
1475
|
-
self._last_active[location] = "".join(map(str, avail))
|
1476
|
-
return avail, sel
|
1229
|
+
return None # pragma: no cover
|
1477
1230
|
|
1478
1231
|
def _schedules(self, location: str) -> tuple[list[str], str]:
|
1479
1232
|
"""Helper-function for smile.py: _device_data_climate().
|
@@ -1484,11 +1237,6 @@ class SmileHelper:
|
|
1484
1237
|
available: list[str] = [NONE]
|
1485
1238
|
rule_ids: dict[str, dict[str, str]] = {}
|
1486
1239
|
selected = NONE
|
1487
|
-
|
1488
|
-
# Legacy Anna schedule, only one schedule allowed
|
1489
|
-
if self._smile_legacy:
|
1490
|
-
return self._schedules_legacy(available, location, selected)
|
1491
|
-
|
1492
1240
|
# Adam schedules, one schedule can be linked to various locations
|
1493
1241
|
# self._last_active contains the locations and the active schedule name per location, or None
|
1494
1242
|
if location not in self._last_active:
|
@@ -1547,7 +1295,6 @@ class SmileHelper:
|
|
1547
1295
|
locator = f'./location[@id="{obj_id}"]/logs/point_log[type="{measurement}"]/period/measurement'
|
1548
1296
|
if (found := search.find(locator)) is not None:
|
1549
1297
|
val = format_measure(found.text, NONE)
|
1550
|
-
return val
|
1551
1298
|
|
1552
1299
|
return val
|
1553
1300
|
|
@@ -1558,9 +1305,6 @@ class SmileHelper:
|
|
1558
1305
|
"""
|
1559
1306
|
actuator = "actuator_functionalities"
|
1560
1307
|
func_type = "relay_functionality"
|
1561
|
-
if self._stretch_v2:
|
1562
|
-
actuator = "actuators"
|
1563
|
-
func_type = "relay"
|
1564
1308
|
if xml.find("type").text not in SPECIAL_PLUG_TYPES:
|
1565
1309
|
locator = f"./{actuator}/{func_type}/lock"
|
1566
1310
|
if (found := xml.find(locator)) is not None:
|
@@ -1579,8 +1323,19 @@ class SmileHelper:
|
|
1579
1323
|
if (state := xml.find(locator)) is not None:
|
1580
1324
|
data["switches"][name] = state.text == "on"
|
1581
1325
|
self._count += 1
|
1582
|
-
|
1583
|
-
|
1584
|
-
|
1585
|
-
|
1586
|
-
|
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
|
+
)
|