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
@@ -0,0 +1,775 @@
|
|
1
|
+
"""Use of this source code is governed by the MIT license found in the LICENSE file.
|
2
|
+
|
3
|
+
Plugwise Smile protocol helpers.
|
4
|
+
"""
|
5
|
+
from __future__ import annotations
|
6
|
+
|
7
|
+
import datetime as dt
|
8
|
+
from typing import cast
|
9
|
+
|
10
|
+
from plugwise.constants import (
|
11
|
+
ACTIVE_ACTUATORS,
|
12
|
+
ACTUATOR_CLASSES,
|
13
|
+
ANNA,
|
14
|
+
APPLIANCES,
|
15
|
+
ATTR_NAME,
|
16
|
+
ATTR_UNIT_OF_MEASUREMENT,
|
17
|
+
BINARY_SENSORS,
|
18
|
+
DATA,
|
19
|
+
DEVICE_MEASUREMENTS,
|
20
|
+
ENERGY_WATT_HOUR,
|
21
|
+
FAKE_APPL,
|
22
|
+
FAKE_LOC,
|
23
|
+
HEATER_CENTRAL_MEASUREMENTS,
|
24
|
+
LIMITS,
|
25
|
+
NONE,
|
26
|
+
OBSOLETE_MEASUREMENTS,
|
27
|
+
P1_LEGACY_MEASUREMENTS,
|
28
|
+
SENSORS,
|
29
|
+
SPECIAL_PLUG_TYPES,
|
30
|
+
SPECIALS,
|
31
|
+
SWITCH_GROUP_TYPES,
|
32
|
+
SWITCHES,
|
33
|
+
TEMP_CELSIUS,
|
34
|
+
THERMOSTAT_CLASSES,
|
35
|
+
UOM,
|
36
|
+
ActuatorData,
|
37
|
+
ActuatorDataType,
|
38
|
+
ActuatorType,
|
39
|
+
ApplianceType,
|
40
|
+
BinarySensorType,
|
41
|
+
DeviceData,
|
42
|
+
GatewayData,
|
43
|
+
ModelData,
|
44
|
+
SensorType,
|
45
|
+
SpecialType,
|
46
|
+
SwitchType,
|
47
|
+
ThermoLoc,
|
48
|
+
)
|
49
|
+
from plugwise.util import format_measure, power_data_local_format, version_to_model
|
50
|
+
|
51
|
+
# This way of importing aiohttp is because of patch/mocking in testing (aiohttp timeouts)
|
52
|
+
from defusedxml import ElementTree as etree
|
53
|
+
from munch import Munch
|
54
|
+
import semver
|
55
|
+
|
56
|
+
|
57
|
+
def etree_to_dict(element: etree) -> dict[str, str]:
|
58
|
+
"""Helper-function translating xml Element to dict."""
|
59
|
+
node: dict[str, str] = {}
|
60
|
+
if element is not None:
|
61
|
+
node.update(element.items())
|
62
|
+
|
63
|
+
return node
|
64
|
+
|
65
|
+
|
66
|
+
class SmileLegacyHelper:
|
67
|
+
"""The SmileLegacyHelper class."""
|
68
|
+
|
69
|
+
def __init__(self) -> None:
|
70
|
+
"""Set the constructor for this class."""
|
71
|
+
self._appliances: etree
|
72
|
+
self._count: int
|
73
|
+
self._domain_objects: etree
|
74
|
+
self._heater_id: str
|
75
|
+
self._home_location: str
|
76
|
+
self._is_thermostat: bool
|
77
|
+
self._last_modified: dict[str, str] = {}
|
78
|
+
self._locations: etree
|
79
|
+
self._modules: etree
|
80
|
+
self._notifications: dict[str, dict[str, str]] = {}
|
81
|
+
self._on_off_device: bool
|
82
|
+
self._opentherm_device: bool
|
83
|
+
self._outdoor_temp: float
|
84
|
+
self._status: etree
|
85
|
+
self._stretch_v2: bool
|
86
|
+
self._system: etree
|
87
|
+
|
88
|
+
self.device_items: int = 0
|
89
|
+
self.gateway_id: str
|
90
|
+
self.gw_data: GatewayData = {}
|
91
|
+
self.gw_devices: dict[str, DeviceData] = {}
|
92
|
+
self.loc_data: dict[str, ThermoLoc]
|
93
|
+
self.smile_fw_version: str | None = None
|
94
|
+
self.smile_hw_version: str | None = None
|
95
|
+
self.smile_legacy = False
|
96
|
+
self.smile_mac_address: str | None = None
|
97
|
+
self.smile_model: str
|
98
|
+
self.smile_name: str
|
99
|
+
self.smile_type: str
|
100
|
+
self.smile_version: tuple[str, semver.version.Version]
|
101
|
+
self.smile_zigbee_mac_address: str | None = None
|
102
|
+
|
103
|
+
def smile(self, name: str) -> bool:
|
104
|
+
"""Helper-function checking the smile-name."""
|
105
|
+
return self.smile_name == name
|
106
|
+
|
107
|
+
def _all_locations(self) -> None:
|
108
|
+
"""Collect all locations."""
|
109
|
+
loc = Munch()
|
110
|
+
|
111
|
+
# Legacy Anna without outdoor_temp and Stretches have no locations, create fake location-data
|
112
|
+
if not (locations := self._locations.findall("./location")):
|
113
|
+
self._home_location = FAKE_LOC
|
114
|
+
self.loc_data[FAKE_LOC] = {"name": "Home"}
|
115
|
+
return
|
116
|
+
|
117
|
+
for location in locations:
|
118
|
+
loc.name = location.find("name").text
|
119
|
+
loc.loc_id = location.attrib["id"]
|
120
|
+
# Filter the valid single location for P1 legacy: services not empty
|
121
|
+
locator = "./services"
|
122
|
+
if (
|
123
|
+
self.smile_type == "power"
|
124
|
+
and len(location.find(locator)) == 0
|
125
|
+
):
|
126
|
+
continue
|
127
|
+
|
128
|
+
if loc.name == "Home":
|
129
|
+
self._home_location = loc.loc_id
|
130
|
+
# Replace location-name for P1 legacy, can contain privacy-related info
|
131
|
+
if self.smile_type == "power":
|
132
|
+
loc.name = "Home"
|
133
|
+
self._home_location = loc.loc_id
|
134
|
+
|
135
|
+
self.loc_data[loc.loc_id] = {"name": loc.name}
|
136
|
+
|
137
|
+
def _get_module_data(
|
138
|
+
self, appliance: etree, locator: str, mod_type: str
|
139
|
+
) -> ModelData:
|
140
|
+
"""Helper-function for _energy_device_info_finder() and _appliance_info_finder().
|
141
|
+
|
142
|
+
Collect requested info from MODULES.
|
143
|
+
"""
|
144
|
+
model_data: ModelData = {
|
145
|
+
"contents": False,
|
146
|
+
"firmware_version": None,
|
147
|
+
"hardware_version": None,
|
148
|
+
"reachable": None,
|
149
|
+
"vendor_name": None,
|
150
|
+
"vendor_model": None,
|
151
|
+
"zigbee_mac_address": None,
|
152
|
+
}
|
153
|
+
if (appl_search := appliance.find(locator)) is not None:
|
154
|
+
link_id = appl_search.attrib["id"]
|
155
|
+
loc = f".//{mod_type}[@id='{link_id}']...."
|
156
|
+
# Not possible to walrus for some reason...
|
157
|
+
module = self._modules.find(loc)
|
158
|
+
if module is not None: # pylint: disable=consider-using-assignment-expr
|
159
|
+
model_data["contents"] = True
|
160
|
+
if (vendor_name := module.find("vendor_name").text) is not None:
|
161
|
+
model_data["vendor_name"] = vendor_name
|
162
|
+
if "Plugwise" in vendor_name:
|
163
|
+
model_data["vendor_name"] = vendor_name.split(" ", 1)[0]
|
164
|
+
model_data["vendor_model"] = module.find("vendor_model").text
|
165
|
+
model_data["hardware_version"] = module.find("hardware_version").text
|
166
|
+
model_data["firmware_version"] = module.find("firmware_version").text
|
167
|
+
# Stretches
|
168
|
+
if (router := module.find("./protocols/network_router")) is not None:
|
169
|
+
model_data["zigbee_mac_address"] = router.find("mac_address").text
|
170
|
+
# Also look for the Circle+/Stealth M+
|
171
|
+
if (coord := module.find("./protocols/network_coordinator")) is not None:
|
172
|
+
model_data["zigbee_mac_address"] = coord.find("mac_address").text
|
173
|
+
|
174
|
+
return model_data
|
175
|
+
|
176
|
+
def _energy_device_info_finder(self, appliance: etree, appl: Munch) -> Munch:
|
177
|
+
"""Helper-function for _appliance_info_finder().
|
178
|
+
|
179
|
+
Collect energy device info (Circle, Plug, Stealth): firmware, model and vendor name.
|
180
|
+
"""
|
181
|
+
if self.smile_type in ("power", "stretch"):
|
182
|
+
locator = "./services/electricity_point_meter"
|
183
|
+
mod_type = "electricity_point_meter"
|
184
|
+
|
185
|
+
module_data = self._get_module_data(appliance, locator, mod_type)
|
186
|
+
# Filter appliance without zigbee_mac, it's an orphaned device
|
187
|
+
appl.zigbee_mac = module_data["zigbee_mac_address"]
|
188
|
+
if appl.zigbee_mac is None and self.smile_type != "power":
|
189
|
+
return None
|
190
|
+
|
191
|
+
appl.hardware = module_data["hardware_version"]
|
192
|
+
appl.model = module_data["vendor_model"]
|
193
|
+
appl.vendor_name = module_data["vendor_name"]
|
194
|
+
if appl.hardware is not None:
|
195
|
+
hw_version = appl.hardware.replace("-", "")
|
196
|
+
appl.model = version_to_model(hw_version)
|
197
|
+
appl.firmware = module_data["firmware_version"]
|
198
|
+
|
199
|
+
return appl
|
200
|
+
|
201
|
+
return appl # pragma: no cover
|
202
|
+
|
203
|
+
def _appliance_info_finder(self, appliance: etree, appl: Munch) -> Munch:
|
204
|
+
"""Collect device info (Smile/Stretch, Thermostats, OpenTherm/On-Off): firmware, model and vendor name."""
|
205
|
+
# Collect thermostat device info
|
206
|
+
if appl.pwclass in THERMOSTAT_CLASSES:
|
207
|
+
locator = "./logs/point_log[type='thermostat']/thermostat"
|
208
|
+
mod_type = "thermostat"
|
209
|
+
module_data = self._get_module_data(appliance, locator, mod_type)
|
210
|
+
appl.vendor_name = module_data["vendor_name"]
|
211
|
+
appl.model = module_data["vendor_model"]
|
212
|
+
appl.hardware = module_data["hardware_version"]
|
213
|
+
appl.firmware = module_data["firmware_version"]
|
214
|
+
appl.zigbee_mac = module_data["zigbee_mac_address"]
|
215
|
+
return appl
|
216
|
+
|
217
|
+
# Collect heater_central device info
|
218
|
+
if appl.pwclass == "heater_central":
|
219
|
+
# Remove heater_central when no active device present
|
220
|
+
if not self._opentherm_device and not self._on_off_device:
|
221
|
+
return None
|
222
|
+
|
223
|
+
# Find the valid heater_central
|
224
|
+
self._heater_id = self._check_heater_central()
|
225
|
+
|
226
|
+
# Info for On-Off device
|
227
|
+
if self._on_off_device:
|
228
|
+
appl.name = "OnOff" # pragma: no cover
|
229
|
+
appl.vendor_name = None # pragma: no cover
|
230
|
+
appl.model = "Unknown" # pragma: no cover
|
231
|
+
return appl # pragma: no cover
|
232
|
+
|
233
|
+
# Info for OpenTherm device
|
234
|
+
appl.name = "OpenTherm"
|
235
|
+
locator1 = "./logs/point_log[type='flame_state']/boiler_state"
|
236
|
+
locator2 = "./services/boiler_state"
|
237
|
+
mod_type = "boiler_state"
|
238
|
+
module_data = self._get_module_data(appliance, locator1, mod_type)
|
239
|
+
if not module_data["contents"]:
|
240
|
+
module_data = self._get_module_data(appliance, locator2, mod_type)
|
241
|
+
appl.vendor_name = module_data["vendor_name"]
|
242
|
+
appl.hardware = module_data["hardware_version"]
|
243
|
+
appl.model = module_data["vendor_model"]
|
244
|
+
if appl.model is None:
|
245
|
+
appl.model = "Generic heater"
|
246
|
+
|
247
|
+
return appl
|
248
|
+
|
249
|
+
# Collect info from Stretches
|
250
|
+
appl = self._energy_device_info_finder(appliance, appl)
|
251
|
+
|
252
|
+
return appl
|
253
|
+
|
254
|
+
def _check_heater_central(self) -> str:
|
255
|
+
"""Find the valid heater_central, helper-function for _appliance_info_finder().
|
256
|
+
|
257
|
+
Solution for Core Issue #104433,
|
258
|
+
for a system that has two heater_central appliances.
|
259
|
+
"""
|
260
|
+
locator = "./appliance[type='heater_central']"
|
261
|
+
hc_count = 0
|
262
|
+
hc_list: list[dict[str, bool]] = []
|
263
|
+
for heater_central in self._appliances.findall(locator):
|
264
|
+
hc_count += 1
|
265
|
+
hc_id: str = heater_central.attrib["id"]
|
266
|
+
has_actuators: bool = (
|
267
|
+
heater_central.find("actuator_functionalities/") is not None
|
268
|
+
)
|
269
|
+
hc_list.append({hc_id: has_actuators})
|
270
|
+
|
271
|
+
heater_central_id = list(hc_list[0].keys())[0]
|
272
|
+
if hc_count > 1:
|
273
|
+
for item in hc_list: # pragma: no cover
|
274
|
+
for key, value in item.items(): # pragma: no cover
|
275
|
+
if value: # pragma: no cover
|
276
|
+
heater_central_id = key # pragma: no cover
|
277
|
+
# Stop when a valid id is found
|
278
|
+
break # pragma: no cover
|
279
|
+
|
280
|
+
return heater_central_id
|
281
|
+
|
282
|
+
def _p1_smartmeter_info_finder(self, appl: Munch) -> None:
|
283
|
+
"""Collect P1 DSMR Smartmeter info."""
|
284
|
+
loc_id = next(iter(self.loc_data.keys()))
|
285
|
+
appl.dev_id = loc_id
|
286
|
+
appl.location = loc_id
|
287
|
+
appl.mac = None
|
288
|
+
appl.model = self.smile_model
|
289
|
+
appl.name = "P1"
|
290
|
+
appl.pwclass = "smartmeter"
|
291
|
+
appl.zigbee_mac = None
|
292
|
+
location = self._locations.find(f'./location[@id="{loc_id}"]')
|
293
|
+
appl = self._energy_device_info_finder(location, appl)
|
294
|
+
|
295
|
+
self.gw_devices[appl.dev_id] = {"dev_class": appl.pwclass}
|
296
|
+
self._count += 1
|
297
|
+
|
298
|
+
for key, value in {
|
299
|
+
"firmware": appl.firmware,
|
300
|
+
"hardware": appl.hardware,
|
301
|
+
"location": appl.location,
|
302
|
+
"mac_address": appl.mac,
|
303
|
+
"model": appl.model,
|
304
|
+
"name": appl.name,
|
305
|
+
"zigbee_mac_address": appl.zigbee_mac,
|
306
|
+
"vendor": appl.vendor_name,
|
307
|
+
}.items():
|
308
|
+
if value is not None or key == "location":
|
309
|
+
p1_key = cast(ApplianceType, key)
|
310
|
+
self.gw_devices[appl.dev_id][p1_key] = value
|
311
|
+
self._count += 1
|
312
|
+
|
313
|
+
def _create_legacy_gateway(self) -> None:
|
314
|
+
"""Create the (missing) gateway devices for legacy Anna, P1 and Stretch.
|
315
|
+
|
316
|
+
Use the home_location or FAKE_APPL as device id.
|
317
|
+
"""
|
318
|
+
self.gateway_id = self._home_location
|
319
|
+
if self.smile_type == "power":
|
320
|
+
self.gateway_id = FAKE_APPL
|
321
|
+
|
322
|
+
self.gw_devices[self.gateway_id] = {"dev_class": "gateway"}
|
323
|
+
self._count += 1
|
324
|
+
for key, value in {
|
325
|
+
"firmware": self.smile_fw_version,
|
326
|
+
"location": self._home_location,
|
327
|
+
"mac_address": self.smile_mac_address,
|
328
|
+
"model": self.smile_model,
|
329
|
+
"name": self.smile_name,
|
330
|
+
"zigbee_mac_address": self.smile_zigbee_mac_address,
|
331
|
+
"vendor": "Plugwise",
|
332
|
+
}.items():
|
333
|
+
if value is not None:
|
334
|
+
gw_key = cast(ApplianceType, key)
|
335
|
+
self.gw_devices[self.gateway_id][gw_key] = value
|
336
|
+
self._count += 1
|
337
|
+
|
338
|
+
def _all_appliances(self) -> None:
|
339
|
+
"""Collect all appliances with relevant info."""
|
340
|
+
self._count = 0
|
341
|
+
self._all_locations()
|
342
|
+
|
343
|
+
self._create_legacy_gateway()
|
344
|
+
# For legacy P1 collect the connected SmartMeter info
|
345
|
+
if self.smile_type == "power":
|
346
|
+
appl = Munch()
|
347
|
+
self._p1_smartmeter_info_finder(appl)
|
348
|
+
# Legacy P1 has no more devices
|
349
|
+
return
|
350
|
+
|
351
|
+
for appliance in self._appliances.findall("./appliance"):
|
352
|
+
appl = Munch()
|
353
|
+
appl.pwclass = appliance.find("type").text
|
354
|
+
# Skip thermostats that have this key, should be an orphaned device (Core #81712)
|
355
|
+
if (
|
356
|
+
appl.pwclass == "thermostat"
|
357
|
+
and appliance.find("actuator_functionalities/") is None
|
358
|
+
):
|
359
|
+
continue # pragma: no cover
|
360
|
+
|
361
|
+
appl.location = self._home_location
|
362
|
+
appl.dev_id = appliance.attrib["id"]
|
363
|
+
appl.name = appliance.find("name").text
|
364
|
+
appl.model = appl.pwclass.replace("_", " ").title()
|
365
|
+
appl.firmware = None
|
366
|
+
appl.hardware = None
|
367
|
+
appl.mac = None
|
368
|
+
appl.zigbee_mac = None
|
369
|
+
appl.vendor_name = None
|
370
|
+
|
371
|
+
# Determine class for this appliance
|
372
|
+
# Skip on heater_central when no active device present or on orphaned stretch devices
|
373
|
+
if not (appl := self._appliance_info_finder(appliance, appl)):
|
374
|
+
continue
|
375
|
+
|
376
|
+
# Skip orphaned heater_central (Core Issue #104433)
|
377
|
+
if appl.pwclass == "heater_central" and appl.dev_id != self._heater_id:
|
378
|
+
continue # pragma: no cover
|
379
|
+
|
380
|
+
self.gw_devices[appl.dev_id] = {"dev_class": appl.pwclass}
|
381
|
+
self._count += 1
|
382
|
+
for key, value in {
|
383
|
+
"firmware": appl.firmware,
|
384
|
+
"hardware": appl.hardware,
|
385
|
+
"location": appl.location,
|
386
|
+
"mac_address": appl.mac,
|
387
|
+
"model": appl.model,
|
388
|
+
"name": appl.name,
|
389
|
+
"zigbee_mac_address": appl.zigbee_mac,
|
390
|
+
"vendor": appl.vendor_name,
|
391
|
+
}.items():
|
392
|
+
if value is not None or key == "location":
|
393
|
+
appl_key = cast(ApplianceType, key)
|
394
|
+
self.gw_devices[appl.dev_id][appl_key] = value
|
395
|
+
self._count += 1
|
396
|
+
|
397
|
+
# Place the gateway and optional heater_central devices as 1st and 2nd
|
398
|
+
for dev_class in ("heater_central", "gateway"):
|
399
|
+
for dev_id, device in dict(self.gw_devices).items():
|
400
|
+
if device["dev_class"] == dev_class:
|
401
|
+
tmp_device = device
|
402
|
+
self.gw_devices.pop(dev_id)
|
403
|
+
cleared_dict = self.gw_devices
|
404
|
+
add_to_front = {dev_id: tmp_device}
|
405
|
+
self.gw_devices = {**add_to_front, **cleared_dict}
|
406
|
+
|
407
|
+
def _presets(self) -> dict[str, list[float]]:
|
408
|
+
"""Helper-function for presets() - collect Presets for a legacy Anna."""
|
409
|
+
presets: dict[str, list[float]] = {}
|
410
|
+
for directive in self._domain_objects.findall("rule/directives/when/then"):
|
411
|
+
if directive is not None and directive.get("icon") is not None:
|
412
|
+
# Ensure list of heating_setpoint, cooling_setpoint
|
413
|
+
presets[directive.attrib["icon"]] = [
|
414
|
+
float(directive.attrib["temperature"]),
|
415
|
+
0,
|
416
|
+
]
|
417
|
+
|
418
|
+
return presets
|
419
|
+
|
420
|
+
def _appliance_measurements(
|
421
|
+
self,
|
422
|
+
appliance: etree,
|
423
|
+
data: DeviceData,
|
424
|
+
measurements: dict[str, DATA | UOM],
|
425
|
+
) -> None:
|
426
|
+
"""Helper-function for _get_measurement_data() - collect appliance measurement data."""
|
427
|
+
for measurement, attrs in measurements.items():
|
428
|
+
p_locator = f'.//logs/point_log[type="{measurement}"]/period/measurement'
|
429
|
+
if (appl_p_loc := appliance.find(p_locator)) is not None:
|
430
|
+
if measurement == "domestic_hot_water_state":
|
431
|
+
continue
|
432
|
+
|
433
|
+
# Skip known obsolete measurements
|
434
|
+
updated_date_locator = (
|
435
|
+
f'.//logs/point_log[type="{measurement}"]/updated_date'
|
436
|
+
)
|
437
|
+
if (
|
438
|
+
measurement in OBSOLETE_MEASUREMENTS
|
439
|
+
and (updated_date_key := appliance.find(updated_date_locator))
|
440
|
+
is not None
|
441
|
+
):
|
442
|
+
updated_date = updated_date_key.text.split("T")[0]
|
443
|
+
date_1 = dt.datetime.strptime(updated_date, "%Y-%m-%d")
|
444
|
+
date_2 = dt.datetime.now()
|
445
|
+
if int((date_2 - date_1).days) > 7:
|
446
|
+
continue # pragma: no cover
|
447
|
+
|
448
|
+
if new_name := getattr(attrs, ATTR_NAME, None):
|
449
|
+
measurement = new_name
|
450
|
+
|
451
|
+
match measurement:
|
452
|
+
case _ as measurement if measurement in BINARY_SENSORS:
|
453
|
+
bs_key = cast(BinarySensorType, measurement)
|
454
|
+
bs_value = appl_p_loc.text in ["on", "true"]
|
455
|
+
data["binary_sensors"][bs_key] = bs_value
|
456
|
+
case _ as measurement if measurement in SENSORS:
|
457
|
+
s_key = cast(SensorType, measurement)
|
458
|
+
s_value = format_measure(
|
459
|
+
appl_p_loc.text, getattr(attrs, ATTR_UNIT_OF_MEASUREMENT)
|
460
|
+
)
|
461
|
+
data["sensors"][s_key] = s_value
|
462
|
+
case _ as measurement if measurement in SWITCHES:
|
463
|
+
sw_key = cast(SwitchType, measurement)
|
464
|
+
sw_value = appl_p_loc.text in ["on", "true"]
|
465
|
+
data["switches"][sw_key] = sw_value
|
466
|
+
case _ as measurement if measurement in SPECIALS:
|
467
|
+
sp_key = cast(SpecialType, measurement)
|
468
|
+
sp_value = appl_p_loc.text in ["on", "true"]
|
469
|
+
data[sp_key] = sp_value
|
470
|
+
|
471
|
+
i_locator = f'.//logs/interval_log[type="{measurement}"]/period/measurement'
|
472
|
+
if (appl_i_loc := appliance.find(i_locator)) is not None:
|
473
|
+
name = cast(SensorType, f"{measurement}_interval")
|
474
|
+
data["sensors"][name] = format_measure(
|
475
|
+
appl_i_loc.text, ENERGY_WATT_HOUR
|
476
|
+
)
|
477
|
+
|
478
|
+
self._count += len(data["binary_sensors"])
|
479
|
+
self._count += len(data["sensors"])
|
480
|
+
self._count += len(data["switches"])
|
481
|
+
# Don't count the above top-level dicts, only the remaining single items
|
482
|
+
self._count += len(data) - 3
|
483
|
+
|
484
|
+
def _get_actuator_functionalities(
|
485
|
+
self, xml: etree, device: DeviceData, data: DeviceData
|
486
|
+
) -> None:
|
487
|
+
"""Helper-function for _get_measurement_data()."""
|
488
|
+
for item in ACTIVE_ACTUATORS:
|
489
|
+
# Skip max_dhw_temperature, not initially valid,
|
490
|
+
# skip thermostat for thermo_sensors
|
491
|
+
if item == "max_dhw_temperature" or (
|
492
|
+
item == "thermostat" and device["dev_class"] == "thermo_sensor"
|
493
|
+
):
|
494
|
+
continue
|
495
|
+
|
496
|
+
temp_dict: ActuatorData = {}
|
497
|
+
functionality = "thermostat_functionality"
|
498
|
+
|
499
|
+
# When there is no updated_date-text, skip the actuator
|
500
|
+
updated_date_location = f'.//actuator_functionalities/{functionality}[type="{item}"]/updated_date'
|
501
|
+
if (
|
502
|
+
updated_date_key := xml.find(updated_date_location)
|
503
|
+
) is not None and updated_date_key.text is None:
|
504
|
+
continue # pragma: no cover
|
505
|
+
|
506
|
+
for key in LIMITS:
|
507
|
+
locator = (
|
508
|
+
f'.//actuator_functionalities/{functionality}[type="{item}"]/{key}'
|
509
|
+
)
|
510
|
+
if (pw_function := xml.find(locator)) is not None:
|
511
|
+
act_key = cast(ActuatorDataType, key)
|
512
|
+
temp_dict[act_key] = format_measure(pw_function.text, TEMP_CELSIUS)
|
513
|
+
self._count += 1
|
514
|
+
|
515
|
+
if temp_dict:
|
516
|
+
act_item = cast(ActuatorType, item)
|
517
|
+
data[act_item] = temp_dict
|
518
|
+
|
519
|
+
def _get_measurement_data(self, dev_id: str) -> DeviceData:
|
520
|
+
"""Helper-function for smile.py: _get_device_data().
|
521
|
+
|
522
|
+
Collect the appliance-data based on device id.
|
523
|
+
"""
|
524
|
+
data: DeviceData = {"binary_sensors": {}, "sensors": {}, "switches": {}}
|
525
|
+
# Get P1 smartmeter data from LOCATIONS or MODULES
|
526
|
+
device = self.gw_devices[dev_id]
|
527
|
+
# !! DON'T CHANGE below two if-lines, will break stuff !!
|
528
|
+
if self.smile_type == "power":
|
529
|
+
if device["dev_class"] == "smartmeter":
|
530
|
+
data.update(self._power_data_from_modules())
|
531
|
+
|
532
|
+
return data
|
533
|
+
|
534
|
+
measurements = DEVICE_MEASUREMENTS
|
535
|
+
if self._is_thermostat and dev_id == self._heater_id:
|
536
|
+
measurements = HEATER_CENTRAL_MEASUREMENTS
|
537
|
+
|
538
|
+
if (
|
539
|
+
appliance := self._appliances.find(f'./appliance[@id="{dev_id}"]')
|
540
|
+
) is not None:
|
541
|
+
self._appliance_measurements(appliance, data, measurements)
|
542
|
+
self._get_lock_state(appliance, data)
|
543
|
+
|
544
|
+
if appliance.find("type").text in ACTUATOR_CLASSES:
|
545
|
+
self._get_actuator_functionalities(appliance, device, data)
|
546
|
+
|
547
|
+
# Adam & Anna: the Smile outdoor_temperature is present in DOMAIN_OBJECTS and LOCATIONS - under Home
|
548
|
+
# The outdoor_temperature present in APPLIANCES is a local sensor connected to the active device
|
549
|
+
if self._is_thermostat and dev_id == self.gateway_id:
|
550
|
+
outdoor_temperature = self._object_value(
|
551
|
+
self._home_location, "outdoor_temperature"
|
552
|
+
)
|
553
|
+
if outdoor_temperature is not None:
|
554
|
+
data.update({"sensors": {"outdoor_temperature": outdoor_temperature}})
|
555
|
+
self._count += 1
|
556
|
+
|
557
|
+
if "c_heating_state" in data:
|
558
|
+
data.pop("c_heating_state")
|
559
|
+
self._count -= 1
|
560
|
+
|
561
|
+
return data
|
562
|
+
|
563
|
+
def _thermostat_uri(self) -> str:
|
564
|
+
"""Determine the location-set_temperature uri - from APPLIANCES."""
|
565
|
+
locator = "./appliance[type='thermostat']"
|
566
|
+
appliance_id = self._appliances.find(locator).attrib["id"]
|
567
|
+
|
568
|
+
return f"{APPLIANCES};id={appliance_id}/thermostat"
|
569
|
+
|
570
|
+
def _get_group_switches(self) -> dict[str, DeviceData]:
|
571
|
+
"""Helper-function for smile.py: get_all_devices().
|
572
|
+
|
573
|
+
Collect switching- or pump-group info.
|
574
|
+
"""
|
575
|
+
switch_groups: dict[str, DeviceData] = {}
|
576
|
+
# P1 and Anna don't have switchgroups
|
577
|
+
if self.smile_type == "power" or self.smile(ANNA):
|
578
|
+
return switch_groups
|
579
|
+
|
580
|
+
for group in self._domain_objects.findall("./group"):
|
581
|
+
members: list[str] = []
|
582
|
+
group_id = group.attrib["id"]
|
583
|
+
group_name = group.find("name").text
|
584
|
+
group_type = group.find("type").text
|
585
|
+
group_appliances = group.findall("appliances/appliance")
|
586
|
+
for item in group_appliances:
|
587
|
+
# Check if members are not orphaned - stretch
|
588
|
+
if item.attrib["id"] in self.gw_devices:
|
589
|
+
members.append(item.attrib["id"])
|
590
|
+
|
591
|
+
if group_type in SWITCH_GROUP_TYPES and members:
|
592
|
+
switch_groups.update(
|
593
|
+
{
|
594
|
+
group_id: {
|
595
|
+
"dev_class": group_type,
|
596
|
+
"model": "Switchgroup",
|
597
|
+
"name": group_name,
|
598
|
+
"members": members,
|
599
|
+
},
|
600
|
+
},
|
601
|
+
)
|
602
|
+
self._count += 4
|
603
|
+
|
604
|
+
return switch_groups
|
605
|
+
|
606
|
+
def power_data_energy_diff(
|
607
|
+
self,
|
608
|
+
measurement: str,
|
609
|
+
net_string: SensorType,
|
610
|
+
f_val: float | int,
|
611
|
+
direct_data: DeviceData,
|
612
|
+
) -> DeviceData:
|
613
|
+
"""Calculate differential energy."""
|
614
|
+
if (
|
615
|
+
"electricity" in measurement
|
616
|
+
and "phase" not in measurement
|
617
|
+
and "interval" not in net_string
|
618
|
+
):
|
619
|
+
diff = 1
|
620
|
+
if "produced" in measurement:
|
621
|
+
diff = -1
|
622
|
+
if net_string not in direct_data["sensors"]:
|
623
|
+
tmp_val: float | int = 0
|
624
|
+
else:
|
625
|
+
tmp_val = direct_data["sensors"][net_string]
|
626
|
+
|
627
|
+
if isinstance(f_val, int):
|
628
|
+
tmp_val += f_val * diff
|
629
|
+
else:
|
630
|
+
tmp_val += float(f_val * diff)
|
631
|
+
tmp_val = float(f"{round(tmp_val, 3):.3f}")
|
632
|
+
|
633
|
+
direct_data["sensors"][net_string] = tmp_val
|
634
|
+
|
635
|
+
return direct_data
|
636
|
+
|
637
|
+
def _power_data_peak_value(self, loc: Munch) -> Munch:
|
638
|
+
"""Helper-function for _power_data_from_location() and _power_data_from_modules()."""
|
639
|
+
loc.found = True
|
640
|
+
# If locator not found for P1 legacy electricity_point_meter or gas_*_meter data
|
641
|
+
if loc.logs.find(loc.locator) is None:
|
642
|
+
if "meter" in loc.log_type and (
|
643
|
+
"point" in loc.log_type or "gas" in loc.measurement
|
644
|
+
):
|
645
|
+
# Avoid double processing by skipping one peak-list option
|
646
|
+
if loc.peak_select == "nl_offpeak":
|
647
|
+
loc.found = False
|
648
|
+
return loc
|
649
|
+
|
650
|
+
loc.locator = (
|
651
|
+
f"./{loc.meas_list[0]}_{loc.log_type}/"
|
652
|
+
f'measurement[@directionality="{loc.meas_list[1]}"]'
|
653
|
+
)
|
654
|
+
if loc.logs.find(loc.locator) is None:
|
655
|
+
loc.found = False
|
656
|
+
return loc
|
657
|
+
else:
|
658
|
+
loc.found = False
|
659
|
+
return loc
|
660
|
+
|
661
|
+
if (peak := loc.peak_select.split("_")[1]) == "offpeak":
|
662
|
+
peak = "off_peak"
|
663
|
+
log_found = loc.log_type.split("_")[0]
|
664
|
+
loc.key_string = f"{loc.measurement}_{peak}_{log_found}"
|
665
|
+
if "gas" in loc.measurement or loc.log_type == "point_meter":
|
666
|
+
loc.key_string = f"{loc.measurement}_{log_found}"
|
667
|
+
loc.net_string = f"net_electricity_{log_found}"
|
668
|
+
val = loc.logs.find(loc.locator).text
|
669
|
+
loc.f_val = power_data_local_format(loc.attrs, loc.key_string, val)
|
670
|
+
|
671
|
+
return loc
|
672
|
+
|
673
|
+
def _power_data_from_modules(self) -> DeviceData:
|
674
|
+
"""Helper-function for smile.py: _get_device_data().
|
675
|
+
|
676
|
+
Collect the power-data from MODULES (P1 legacy only).
|
677
|
+
"""
|
678
|
+
direct_data: DeviceData = {"sensors": {}}
|
679
|
+
loc = Munch()
|
680
|
+
mod_list: list[str] = ["interval_meter", "cumulative_meter", "point_meter"]
|
681
|
+
peak_list: list[str] = ["nl_peak", "nl_offpeak"]
|
682
|
+
t_string = "tariff_indicator"
|
683
|
+
|
684
|
+
search = self._modules
|
685
|
+
mod_logs = search.findall("./module/services")
|
686
|
+
for loc.measurement, loc.attrs in P1_LEGACY_MEASUREMENTS.items():
|
687
|
+
loc.meas_list = loc.measurement.split("_")
|
688
|
+
for loc.logs in mod_logs:
|
689
|
+
for loc.log_type in mod_list:
|
690
|
+
for loc.peak_select in peak_list:
|
691
|
+
loc.locator = (
|
692
|
+
f"./{loc.meas_list[0]}_{loc.log_type}/measurement"
|
693
|
+
f'[@directionality="{loc.meas_list[1]}"][@{t_string}="{loc.peak_select}"]'
|
694
|
+
)
|
695
|
+
loc = self._power_data_peak_value(loc)
|
696
|
+
if not loc.found:
|
697
|
+
continue
|
698
|
+
|
699
|
+
direct_data = self.power_data_energy_diff(
|
700
|
+
loc.measurement, loc.net_string, loc.f_val, direct_data
|
701
|
+
)
|
702
|
+
key = cast(SensorType, loc.key_string)
|
703
|
+
direct_data["sensors"][key] = loc.f_val
|
704
|
+
|
705
|
+
self._count += len(direct_data["sensors"])
|
706
|
+
return direct_data
|
707
|
+
|
708
|
+
def _preset(self) -> str | None:
|
709
|
+
"""Helper-function for smile.py: device_data_climate().
|
710
|
+
|
711
|
+
Collect the active preset based on the active rule.
|
712
|
+
"""
|
713
|
+
locator = "./rule[active='true']/directives/when/then"
|
714
|
+
if (
|
715
|
+
not (active_rule := etree_to_dict(self._domain_objects.find(locator)))
|
716
|
+
or "icon" not in active_rule
|
717
|
+
):
|
718
|
+
return None
|
719
|
+
|
720
|
+
return active_rule["icon"]
|
721
|
+
|
722
|
+
def _schedules(self) -> tuple[list[str], str]:
|
723
|
+
"""Collect available schedules/schedules for the legacy thermostat."""
|
724
|
+
available: list[str] = [NONE]
|
725
|
+
selected = NONE
|
726
|
+
name: str | None = None
|
727
|
+
|
728
|
+
search = self._domain_objects
|
729
|
+
for schedule in search.findall("./rule"):
|
730
|
+
if rule_name := schedule.find("name").text:
|
731
|
+
if "preset" not in rule_name:
|
732
|
+
name = rule_name
|
733
|
+
|
734
|
+
log_type = "schedule_state"
|
735
|
+
locator = f"./appliance[type='thermostat']/logs/point_log[type='{log_type}']/period/measurement"
|
736
|
+
active = False
|
737
|
+
if (result := search.find(locator)) is not None:
|
738
|
+
active = result.text == "on"
|
739
|
+
|
740
|
+
if name is not None:
|
741
|
+
available = [name]
|
742
|
+
if active:
|
743
|
+
selected = name
|
744
|
+
|
745
|
+
return available, selected
|
746
|
+
|
747
|
+
def _object_value(self, obj_id: str, measurement: str) -> float | int | None:
|
748
|
+
"""Helper-function for smile.py: _get_device_data() and _device_data_anna().
|
749
|
+
|
750
|
+
Obtain the value/state for the given object from a location in DOMAIN_OBJECTS
|
751
|
+
"""
|
752
|
+
val: float | int | None = None
|
753
|
+
search = self._domain_objects
|
754
|
+
locator = f'./location[@id="{obj_id}"]/logs/point_log[type="{measurement}"]/period/measurement'
|
755
|
+
if (found := search.find(locator)) is not None:
|
756
|
+
val = format_measure(found.text, NONE)
|
757
|
+
return val
|
758
|
+
|
759
|
+
return val
|
760
|
+
|
761
|
+
def _get_lock_state(self, xml: etree, data: DeviceData) -> None:
|
762
|
+
"""Helper-function for _get_measurement_data().
|
763
|
+
|
764
|
+
Adam & Stretches: obtain the relay-switch lock state.
|
765
|
+
"""
|
766
|
+
actuator = "actuator_functionalities"
|
767
|
+
func_type = "relay_functionality"
|
768
|
+
if self._stretch_v2:
|
769
|
+
actuator = "actuators"
|
770
|
+
func_type = "relay"
|
771
|
+
if xml.find("type").text not in SPECIAL_PLUG_TYPES:
|
772
|
+
locator = f"./{actuator}/{func_type}/lock"
|
773
|
+
if (found := xml.find(locator)) is not None:
|
774
|
+
data["switches"]["lock"] = found.text == "true"
|
775
|
+
self._count += 1
|