plugwise 0.35.4__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- plugwise/__init__.py +890 -0
- plugwise/constants.py +547 -0
- plugwise/exceptions.py +49 -0
- plugwise/helper.py +1552 -0
- plugwise/py.typed +0 -0
- plugwise/util.py +65 -0
- plugwise-0.35.4.dist-info/LICENSE +21 -0
- plugwise-0.35.4.dist-info/METADATA +146 -0
- plugwise-0.35.4.dist-info/RECORD +11 -0
- plugwise-0.35.4.dist-info/WHEEL +5 -0
- plugwise-0.35.4.dist-info/top_level.txt +1 -0
plugwise/helper.py
ADDED
@@ -0,0 +1,1552 @@
|
|
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 asyncio
|
8
|
+
import datetime as dt
|
9
|
+
from typing import cast
|
10
|
+
|
11
|
+
# This way of importing aiohttp is because of patch/mocking in testing (aiohttp timeouts)
|
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 (
|
22
|
+
ACTIVE_ACTUATORS,
|
23
|
+
ACTUATOR_CLASSES,
|
24
|
+
ADAM,
|
25
|
+
ANNA,
|
26
|
+
APPLIANCES,
|
27
|
+
ATTR_NAME,
|
28
|
+
ATTR_UNIT_OF_MEASUREMENT,
|
29
|
+
BINARY_SENSORS,
|
30
|
+
DATA,
|
31
|
+
DEVICE_MEASUREMENTS,
|
32
|
+
DHW_SETPOINT,
|
33
|
+
ENERGY_KILO_WATT_HOUR,
|
34
|
+
ENERGY_WATT_HOUR,
|
35
|
+
FAKE_APPL,
|
36
|
+
FAKE_LOC,
|
37
|
+
HEATER_CENTRAL_MEASUREMENTS,
|
38
|
+
LIMITS,
|
39
|
+
LOCATIONS,
|
40
|
+
LOGGER,
|
41
|
+
NONE,
|
42
|
+
OBSOLETE_MEASUREMENTS,
|
43
|
+
OFF,
|
44
|
+
P1_LEGACY_MEASUREMENTS,
|
45
|
+
P1_MEASUREMENTS,
|
46
|
+
POWER_WATT,
|
47
|
+
SENSORS,
|
48
|
+
SPECIAL_PLUG_TYPES,
|
49
|
+
SWITCH_GROUP_TYPES,
|
50
|
+
SWITCHES,
|
51
|
+
TEMP_CELSIUS,
|
52
|
+
THERMOSTAT_CLASSES,
|
53
|
+
TOGGLES,
|
54
|
+
UOM,
|
55
|
+
ActuatorData,
|
56
|
+
ActuatorDataType,
|
57
|
+
ActuatorType,
|
58
|
+
ApplianceType,
|
59
|
+
BinarySensorType,
|
60
|
+
DeviceData,
|
61
|
+
GatewayData,
|
62
|
+
ModelData,
|
63
|
+
SensorType,
|
64
|
+
SwitchType,
|
65
|
+
ThermoLoc,
|
66
|
+
ToggleNameType,
|
67
|
+
)
|
68
|
+
from .exceptions import (
|
69
|
+
ConnectionFailedError,
|
70
|
+
InvalidAuthentication,
|
71
|
+
InvalidXMLError,
|
72
|
+
ResponseError,
|
73
|
+
)
|
74
|
+
from .util import escape_illegal_xml_characters, format_measure, version_to_model
|
75
|
+
|
76
|
+
|
77
|
+
def check_model(name: str | None, vendor_name: str | None) -> str | None:
|
78
|
+
"""Model checking before using version_to_model."""
|
79
|
+
if vendor_name == "Plugwise" and ((model := version_to_model(name)) != "Unknown"):
|
80
|
+
return model
|
81
|
+
|
82
|
+
return name
|
83
|
+
|
84
|
+
|
85
|
+
def etree_to_dict(element: etree) -> dict[str, str]:
|
86
|
+
"""Helper-function translating xml Element to dict."""
|
87
|
+
node: dict[str, str] = {}
|
88
|
+
if element is not None:
|
89
|
+
node.update(element.items())
|
90
|
+
|
91
|
+
return node
|
92
|
+
|
93
|
+
|
94
|
+
def power_data_local_format(
|
95
|
+
attrs: dict[str, str], key_string: str, val: str
|
96
|
+
) -> float | int:
|
97
|
+
"""Format power data."""
|
98
|
+
# Special formatting of P1_MEASUREMENT POWER_WATT values, do not move to util-format_measure() function!
|
99
|
+
if all(item in key_string for item in ("electricity", "cumulative")):
|
100
|
+
return format_measure(val, ENERGY_KILO_WATT_HOUR)
|
101
|
+
if (attrs_uom := getattr(attrs, ATTR_UNIT_OF_MEASUREMENT)) == POWER_WATT:
|
102
|
+
return int(round(float(val)))
|
103
|
+
|
104
|
+
return format_measure(val, attrs_uom)
|
105
|
+
|
106
|
+
|
107
|
+
class SmileComm:
|
108
|
+
"""The SmileComm class."""
|
109
|
+
|
110
|
+
def __init__(
|
111
|
+
self,
|
112
|
+
host: str,
|
113
|
+
password: str,
|
114
|
+
username: str,
|
115
|
+
port: int,
|
116
|
+
timeout: float,
|
117
|
+
websession: ClientSession | None,
|
118
|
+
) -> None:
|
119
|
+
"""Set the constructor for this class."""
|
120
|
+
if not websession:
|
121
|
+
aio_timeout = ClientTimeout(total=timeout)
|
122
|
+
|
123
|
+
async def _create_session() -> ClientSession:
|
124
|
+
return ClientSession(timeout=aio_timeout) # pragma: no cover
|
125
|
+
|
126
|
+
loop = asyncio.get_event_loop()
|
127
|
+
if loop.is_running():
|
128
|
+
self._websession = ClientSession(timeout=aio_timeout)
|
129
|
+
else:
|
130
|
+
self._websession = loop.run_until_complete(
|
131
|
+
_create_session()
|
132
|
+
) # pragma: no cover
|
133
|
+
else:
|
134
|
+
self._websession = websession
|
135
|
+
|
136
|
+
# Quickfix IPv6 formatting, not covering
|
137
|
+
if host.count(":") > 2: # pragma: no cover
|
138
|
+
host = f"[{host}]"
|
139
|
+
|
140
|
+
self._auth = BasicAuth(username, password=password)
|
141
|
+
self._endpoint = f"http://{host}:{str(port)}"
|
142
|
+
self._timeout = timeout
|
143
|
+
|
144
|
+
async def _request_validate(self, resp: ClientResponse, method: str) -> etree:
|
145
|
+
"""Helper-function for _request(): validate the returned data."""
|
146
|
+
# Command accepted gives empty body with status 202
|
147
|
+
if resp.status == 202:
|
148
|
+
return
|
149
|
+
# Cornercase for stretch not responding with 202
|
150
|
+
if method == "put" and resp.status == 200:
|
151
|
+
return
|
152
|
+
|
153
|
+
if resp.status == 401:
|
154
|
+
msg = "Invalid Plugwise login, please retry with the correct credentials."
|
155
|
+
LOGGER.error("%s", msg)
|
156
|
+
raise InvalidAuthentication
|
157
|
+
|
158
|
+
if not (result := await resp.text()) or "<error>" in result:
|
159
|
+
LOGGER.warning("Smile response empty or error in %s", result)
|
160
|
+
raise ResponseError
|
161
|
+
|
162
|
+
try:
|
163
|
+
# Encode to ensure utf8 parsing
|
164
|
+
xml = etree.XML(escape_illegal_xml_characters(result).encode())
|
165
|
+
except etree.ParseError:
|
166
|
+
LOGGER.warning("Smile returns invalid XML for %s", self._endpoint)
|
167
|
+
raise InvalidXMLError
|
168
|
+
|
169
|
+
return xml
|
170
|
+
|
171
|
+
async def _request(
|
172
|
+
self,
|
173
|
+
command: str,
|
174
|
+
retry: int = 3,
|
175
|
+
method: str = "get",
|
176
|
+
data: str | None = None,
|
177
|
+
headers: dict[str, str] | None = None,
|
178
|
+
) -> etree:
|
179
|
+
"""Get/put/delete data from a give URL."""
|
180
|
+
resp: ClientResponse
|
181
|
+
url = f"{self._endpoint}{command}"
|
182
|
+
|
183
|
+
try:
|
184
|
+
if method == "delete":
|
185
|
+
resp = await self._websession.delete(url, auth=self._auth)
|
186
|
+
if method == "get":
|
187
|
+
# Work-around for Stretchv2, should not hurt the other smiles
|
188
|
+
headers = {"Accept-Encoding": "gzip"}
|
189
|
+
resp = await self._websession.get(url, headers=headers, auth=self._auth)
|
190
|
+
if method == "put":
|
191
|
+
headers = {"Content-type": "text/xml"}
|
192
|
+
resp = await self._websession.put(
|
193
|
+
url,
|
194
|
+
headers=headers,
|
195
|
+
data=data,
|
196
|
+
auth=self._auth,
|
197
|
+
)
|
198
|
+
except (
|
199
|
+
ClientError
|
200
|
+
) as err: # ClientError is an ancestor class of ServerTimeoutError
|
201
|
+
if retry < 1:
|
202
|
+
LOGGER.warning(
|
203
|
+
"Failed sending %s %s to Plugwise Smile, error: %s",
|
204
|
+
method,
|
205
|
+
command,
|
206
|
+
err,
|
207
|
+
)
|
208
|
+
raise ConnectionFailedError
|
209
|
+
return await self._request(command, retry - 1)
|
210
|
+
|
211
|
+
return await self._request_validate(resp, method)
|
212
|
+
|
213
|
+
async def close_connection(self) -> None:
|
214
|
+
"""Close the Plugwise connection."""
|
215
|
+
await self._websession.close()
|
216
|
+
|
217
|
+
|
218
|
+
class SmileHelper:
|
219
|
+
"""The SmileHelper class."""
|
220
|
+
|
221
|
+
def __init__(self) -> None:
|
222
|
+
"""Set the constructor for this class."""
|
223
|
+
self._appliances: etree
|
224
|
+
self._cooling_activation_outdoor_temp: float
|
225
|
+
self._cooling_deactivation_threshold: float
|
226
|
+
self._cooling_present = False
|
227
|
+
self._count: int
|
228
|
+
self._dhw_allowed_modes: list[str] = []
|
229
|
+
self._domain_objects: etree
|
230
|
+
self._elga = False
|
231
|
+
self._heater_id: str
|
232
|
+
self._home_location: str
|
233
|
+
self._is_thermostat = False
|
234
|
+
self._last_active: dict[str, str | None] = {}
|
235
|
+
self._last_modified: dict[str, str] = {}
|
236
|
+
self._locations: etree
|
237
|
+
self._loc_data: dict[str, ThermoLoc] = {}
|
238
|
+
self._modules: etree
|
239
|
+
self._notifications: dict[str, dict[str, str]] = {}
|
240
|
+
self._on_off_device = False
|
241
|
+
self._opentherm_device = False
|
242
|
+
self._outdoor_temp: float
|
243
|
+
self._reg_allowed_modes: list[str] = []
|
244
|
+
self._schedule_old_states: dict[str, dict[str, str]] = {}
|
245
|
+
self._smile_legacy = False
|
246
|
+
self._status: etree
|
247
|
+
self._stretch_v2 = False
|
248
|
+
self._stretch_v3 = False
|
249
|
+
self._system: etree
|
250
|
+
self._thermo_locs: dict[str, ThermoLoc] = {}
|
251
|
+
###################################################################
|
252
|
+
# '_cooling_enabled' can refer to the state of the Elga heatpump
|
253
|
+
# connected to an Anna. For Elga, 'elga_status_code' in [8, 9]
|
254
|
+
# means cooling mode is available, next to heating mode.
|
255
|
+
# 'elga_status_code' = 8 means cooling is active, 9 means idle.
|
256
|
+
#
|
257
|
+
# '_cooling_enabled' cam refer to the state of the Loria or
|
258
|
+
# Thermastage heatpump connected to an Anna. For these,
|
259
|
+
# 'cooling_enabled' = on means set to cooling mode, instead of to
|
260
|
+
# heating mode.
|
261
|
+
# 'cooling_state' = on means cooling is active.
|
262
|
+
###################################################################
|
263
|
+
self._cooling_active = False
|
264
|
+
self._cooling_enabled = False
|
265
|
+
|
266
|
+
self.device_items: int = 0
|
267
|
+
self.device_list: list[str]
|
268
|
+
self.gateway_id: str
|
269
|
+
self.gw_data: GatewayData = {}
|
270
|
+
self.gw_devices: dict[str, DeviceData] = {}
|
271
|
+
self.smile_fw_version: str | None = None
|
272
|
+
self.smile_hw_version: str | None = None
|
273
|
+
self.smile_mac_address: str | None = None
|
274
|
+
self.smile_model: str
|
275
|
+
self.smile_name: str
|
276
|
+
self.smile_type: str
|
277
|
+
self.smile_version: tuple[str, semver.version.Version]
|
278
|
+
self.smile_zigbee_mac_address: str | None = None
|
279
|
+
self.therms_with_offset_func: list[str] = []
|
280
|
+
|
281
|
+
def smile(self, name: str) -> bool:
|
282
|
+
"""Helper-function checking the smile-name."""
|
283
|
+
return self.smile_name == name
|
284
|
+
|
285
|
+
def _all_locations(self) -> None:
|
286
|
+
"""Collect all locations."""
|
287
|
+
loc = Munch()
|
288
|
+
|
289
|
+
locations = self._locations.findall("./location")
|
290
|
+
# Legacy Anna without outdoor_temp and Stretches have no locations, create fake location-data
|
291
|
+
if not locations and self._smile_legacy:
|
292
|
+
self._home_location = FAKE_LOC
|
293
|
+
self._loc_data[FAKE_LOC] = {"name": "Home"}
|
294
|
+
return
|
295
|
+
|
296
|
+
for location in locations:
|
297
|
+
loc.name = location.find("name").text
|
298
|
+
loc.loc_id = location.attrib["id"]
|
299
|
+
# Filter the valid single location for P1 legacy: services not empty
|
300
|
+
locator = "./services"
|
301
|
+
if (
|
302
|
+
self._smile_legacy
|
303
|
+
and self.smile_type == "power"
|
304
|
+
and len(location.find(locator)) == 0
|
305
|
+
):
|
306
|
+
continue
|
307
|
+
|
308
|
+
if loc.name == "Home":
|
309
|
+
self._home_location = loc.loc_id
|
310
|
+
# Replace location-name for P1 legacy, can contain privacy-related info
|
311
|
+
if self._smile_legacy and self.smile_type == "power":
|
312
|
+
loc.name = "Home"
|
313
|
+
self._home_location = loc.loc_id
|
314
|
+
|
315
|
+
self._loc_data[loc.loc_id] = {"name": loc.name}
|
316
|
+
|
317
|
+
return
|
318
|
+
|
319
|
+
def _get_module_data(
|
320
|
+
self, appliance: etree, locator: str, mod_type: str
|
321
|
+
) -> ModelData:
|
322
|
+
"""Helper-function for _energy_device_info_finder() and _appliance_info_finder().
|
323
|
+
|
324
|
+
Collect requested info from MODULES.
|
325
|
+
"""
|
326
|
+
model_data: ModelData = {
|
327
|
+
"contents": False,
|
328
|
+
"firmware_version": None,
|
329
|
+
"hardware_version": None,
|
330
|
+
"reachable": None,
|
331
|
+
"vendor_name": None,
|
332
|
+
"vendor_model": None,
|
333
|
+
"zigbee_mac_address": None,
|
334
|
+
}
|
335
|
+
if (appl_search := appliance.find(locator)) is not None:
|
336
|
+
link_id = appl_search.attrib["id"]
|
337
|
+
loc = f".//{mod_type}[@id='{link_id}']...."
|
338
|
+
# Not possible to walrus for some reason...
|
339
|
+
module = self._modules.find(loc)
|
340
|
+
if module is not None: # pylint: disable=consider-using-assignment-expr
|
341
|
+
model_data["contents"] = True
|
342
|
+
if (vendor_name := module.find("vendor_name").text) is not None:
|
343
|
+
model_data["vendor_name"] = vendor_name
|
344
|
+
if "Plugwise" in vendor_name:
|
345
|
+
model_data["vendor_name"] = vendor_name.split(" ", 1)[0]
|
346
|
+
model_data["vendor_model"] = module.find("vendor_model").text
|
347
|
+
model_data["hardware_version"] = module.find("hardware_version").text
|
348
|
+
model_data["firmware_version"] = module.find("firmware_version").text
|
349
|
+
# Adam
|
350
|
+
if zb_node := module.find("./protocols/zig_bee_node"):
|
351
|
+
model_data["zigbee_mac_address"] = zb_node.find("mac_address").text
|
352
|
+
model_data["reachable"] = zb_node.find("reachable").text == "true"
|
353
|
+
# Stretches
|
354
|
+
if router := module.find("./protocols/network_router"):
|
355
|
+
model_data["zigbee_mac_address"] = router.find("mac_address").text
|
356
|
+
# Also look for the Circle+/Stealth M+
|
357
|
+
if coord := module.find("./protocols/network_coordinator"):
|
358
|
+
model_data["zigbee_mac_address"] = coord.find("mac_address").text
|
359
|
+
|
360
|
+
return model_data
|
361
|
+
|
362
|
+
def _energy_device_info_finder(self, appliance: etree, appl: Munch) -> Munch:
|
363
|
+
"""Helper-function for _appliance_info_finder().
|
364
|
+
|
365
|
+
Collect energy device info (Circle, Plug, Stealth): firmware, model and vendor name.
|
366
|
+
"""
|
367
|
+
if self.smile_type in ("power", "stretch"):
|
368
|
+
locator = "./services/electricity_point_meter"
|
369
|
+
if not self._smile_legacy:
|
370
|
+
locator = "./logs/point_log/electricity_point_meter"
|
371
|
+
mod_type = "electricity_point_meter"
|
372
|
+
|
373
|
+
module_data = self._get_module_data(appliance, locator, mod_type)
|
374
|
+
# Filter appliance without zigbee_mac, it's an orphaned device
|
375
|
+
appl.zigbee_mac = module_data["zigbee_mac_address"]
|
376
|
+
if appl.zigbee_mac is None and self.smile_type != "power":
|
377
|
+
return None
|
378
|
+
|
379
|
+
appl.hardware = module_data["hardware_version"]
|
380
|
+
appl.model = module_data["vendor_model"]
|
381
|
+
appl.vendor_name = module_data["vendor_name"]
|
382
|
+
if appl.hardware is not None:
|
383
|
+
hw_version = appl.hardware.replace("-", "")
|
384
|
+
appl.model = version_to_model(hw_version)
|
385
|
+
appl.firmware = module_data["firmware_version"]
|
386
|
+
|
387
|
+
return appl
|
388
|
+
|
389
|
+
if self.smile(ADAM):
|
390
|
+
locator = "./logs/interval_log/electricity_interval_meter"
|
391
|
+
mod_type = "electricity_interval_meter"
|
392
|
+
module_data = self._get_module_data(appliance, locator, mod_type)
|
393
|
+
# Filter appliance without zigbee_mac, it's an orphaned device
|
394
|
+
appl.zigbee_mac = module_data["zigbee_mac_address"]
|
395
|
+
if appl.zigbee_mac is None:
|
396
|
+
return None
|
397
|
+
|
398
|
+
appl.vendor_name = module_data["vendor_name"]
|
399
|
+
appl.model = check_model(module_data["vendor_model"], appl.vendor_name)
|
400
|
+
appl.hardware = module_data["hardware_version"]
|
401
|
+
appl.firmware = module_data["firmware_version"]
|
402
|
+
|
403
|
+
return appl
|
404
|
+
|
405
|
+
return appl # pragma: no cover
|
406
|
+
|
407
|
+
def _appliance_info_finder(self, appliance: etree, appl: Munch) -> Munch:
|
408
|
+
"""Collect device info (Smile/Stretch, Thermostats, OpenTherm/On-Off): firmware, model and vendor name."""
|
409
|
+
# Collect gateway device info
|
410
|
+
if appl.pwclass == "gateway":
|
411
|
+
self.gateway_id = appliance.attrib["id"]
|
412
|
+
appl.firmware = self.smile_fw_version
|
413
|
+
appl.hardware = self.smile_hw_version
|
414
|
+
appl.mac = self.smile_mac_address
|
415
|
+
appl.model = self.smile_model
|
416
|
+
appl.name = self.smile_name
|
417
|
+
appl.vendor_name = "Plugwise"
|
418
|
+
|
419
|
+
# Adam: look for the ZigBee MAC address of the Smile
|
420
|
+
if self.smile(ADAM) and (
|
421
|
+
found := self._modules.find(".//protocols/zig_bee_coordinator")
|
422
|
+
):
|
423
|
+
appl.zigbee_mac = found.find("mac_address").text
|
424
|
+
|
425
|
+
# Adam: collect modes and check for cooling, indicating cooling-mode is present
|
426
|
+
reg_mode_list: list[str] = []
|
427
|
+
locator = "./actuator_functionalities/regulation_mode_control_functionality"
|
428
|
+
if (search := appliance.find(locator)) is not None:
|
429
|
+
if search.find("allowed_modes") is not None:
|
430
|
+
for mode in search.find("allowed_modes"):
|
431
|
+
reg_mode_list.append(mode.text)
|
432
|
+
if mode.text == "cooling":
|
433
|
+
self._cooling_present = True
|
434
|
+
self._reg_allowed_modes = reg_mode_list
|
435
|
+
|
436
|
+
return appl
|
437
|
+
|
438
|
+
# Collect thermostat device info
|
439
|
+
if appl.pwclass in THERMOSTAT_CLASSES:
|
440
|
+
locator = "./logs/point_log[type='thermostat']/thermostat"
|
441
|
+
mod_type = "thermostat"
|
442
|
+
module_data = self._get_module_data(appliance, locator, mod_type)
|
443
|
+
appl.vendor_name = module_data["vendor_name"]
|
444
|
+
appl.model = check_model(module_data["vendor_model"], appl.vendor_name)
|
445
|
+
appl.hardware = module_data["hardware_version"]
|
446
|
+
appl.firmware = module_data["firmware_version"]
|
447
|
+
appl.zigbee_mac = module_data["zigbee_mac_address"]
|
448
|
+
|
449
|
+
return appl
|
450
|
+
|
451
|
+
# Collect heater_central device info
|
452
|
+
if appl.pwclass == "heater_central":
|
453
|
+
# Remove heater_central when no active device present
|
454
|
+
if not self._opentherm_device and not self._on_off_device:
|
455
|
+
return None
|
456
|
+
|
457
|
+
# Find the valid heater_central
|
458
|
+
self._heater_id = self._check_heater_central()
|
459
|
+
|
460
|
+
# Info for On-Off device
|
461
|
+
if self._on_off_device:
|
462
|
+
appl.name = "OnOff"
|
463
|
+
appl.vendor_name = None
|
464
|
+
appl.model = "Unknown"
|
465
|
+
return appl
|
466
|
+
|
467
|
+
# Info for OpenTherm device
|
468
|
+
appl.name = "OpenTherm"
|
469
|
+
locator1 = "./logs/point_log[type='flame_state']/boiler_state"
|
470
|
+
locator2 = "./services/boiler_state"
|
471
|
+
mod_type = "boiler_state"
|
472
|
+
module_data = self._get_module_data(appliance, locator1, mod_type)
|
473
|
+
if not module_data["contents"]:
|
474
|
+
module_data = self._get_module_data(appliance, locator2, mod_type)
|
475
|
+
appl.vendor_name = module_data["vendor_name"]
|
476
|
+
appl.hardware = module_data["hardware_version"]
|
477
|
+
appl.model = check_model(module_data["vendor_model"], appl.vendor_name)
|
478
|
+
if appl.model is None:
|
479
|
+
appl.model = (
|
480
|
+
"Generic heater/cooler"
|
481
|
+
if self._cooling_present
|
482
|
+
else "Generic heater"
|
483
|
+
)
|
484
|
+
|
485
|
+
# Anna + Loria: collect dhw control operation modes
|
486
|
+
dhw_mode_list: list[str] = []
|
487
|
+
locator = "./actuator_functionalities/domestic_hot_water_mode_control_functionality"
|
488
|
+
if (search := appliance.find(locator)) is not None:
|
489
|
+
if search.find("allowed_modes") is not None:
|
490
|
+
for mode in search.find("allowed_modes"):
|
491
|
+
dhw_mode_list.append(mode.text)
|
492
|
+
self._dhw_allowed_modes = dhw_mode_list
|
493
|
+
|
494
|
+
return appl
|
495
|
+
|
496
|
+
# Collect info from Stretches
|
497
|
+
appl = self._energy_device_info_finder(appliance, appl)
|
498
|
+
|
499
|
+
return appl
|
500
|
+
|
501
|
+
def _check_heater_central(self) -> str:
|
502
|
+
"""Find the valid heater_central, helper-function for _appliance_info_finder().
|
503
|
+
|
504
|
+
Solution for Core Issue #104433,
|
505
|
+
for a system that has two heater_central appliances.
|
506
|
+
"""
|
507
|
+
locator = "./appliance[type='heater_central']"
|
508
|
+
hc_count = 0
|
509
|
+
hc_list: list[dict[str, bool]] = []
|
510
|
+
for heater_central in self._appliances.findall(locator):
|
511
|
+
hc_count += 1
|
512
|
+
hc_id: str = heater_central.attrib["id"]
|
513
|
+
has_actuators: bool = (
|
514
|
+
heater_central.find("actuator_functionalities/") is not None
|
515
|
+
)
|
516
|
+
hc_list.append({hc_id: has_actuators})
|
517
|
+
|
518
|
+
heater_central_id = list(hc_list[0].keys())[0]
|
519
|
+
if hc_count > 1:
|
520
|
+
for item in hc_list:
|
521
|
+
for key, value in item.items():
|
522
|
+
if value:
|
523
|
+
heater_central_id = key
|
524
|
+
# Stop when a valid id is found
|
525
|
+
break
|
526
|
+
|
527
|
+
return heater_central_id
|
528
|
+
|
529
|
+
def _p1_smartmeter_info_finder(self, appl: Munch) -> None:
|
530
|
+
"""Collect P1 DSMR Smartmeter info."""
|
531
|
+
loc_id = next(iter(self._loc_data.keys()))
|
532
|
+
appl.dev_id = self.gateway_id
|
533
|
+
appl.location = loc_id
|
534
|
+
if self._smile_legacy:
|
535
|
+
appl.dev_id = loc_id
|
536
|
+
appl.mac = None
|
537
|
+
appl.model = self.smile_model
|
538
|
+
appl.name = "P1"
|
539
|
+
appl.pwclass = "smartmeter"
|
540
|
+
appl.zigbee_mac = None
|
541
|
+
location = self._locations.find(f'./location[@id="{loc_id}"]')
|
542
|
+
appl = self._energy_device_info_finder(location, appl)
|
543
|
+
|
544
|
+
self.gw_devices[appl.dev_id] = {"dev_class": appl.pwclass}
|
545
|
+
self._count += 1
|
546
|
+
|
547
|
+
for key, value in {
|
548
|
+
"firmware": appl.firmware,
|
549
|
+
"hardware": appl.hardware,
|
550
|
+
"location": appl.location,
|
551
|
+
"mac_address": appl.mac,
|
552
|
+
"model": appl.model,
|
553
|
+
"name": appl.name,
|
554
|
+
"zigbee_mac_address": appl.zigbee_mac,
|
555
|
+
"vendor": appl.vendor_name,
|
556
|
+
}.items():
|
557
|
+
if value is not None or key == "location":
|
558
|
+
p1_key = cast(ApplianceType, key)
|
559
|
+
self.gw_devices[appl.dev_id][p1_key] = value
|
560
|
+
self._count += 1
|
561
|
+
|
562
|
+
def _create_legacy_gateway(self) -> None:
|
563
|
+
"""Create the (missing) gateway devices for legacy Anna, P1 and Stretch.
|
564
|
+
|
565
|
+
Use the home_location or FAKE_APPL as device id.
|
566
|
+
"""
|
567
|
+
self.gateway_id = self._home_location
|
568
|
+
if self.smile_type == "power":
|
569
|
+
self.gateway_id = FAKE_APPL
|
570
|
+
|
571
|
+
self.gw_devices[self.gateway_id] = {"dev_class": "gateway"}
|
572
|
+
self._count += 1
|
573
|
+
for key, value in {
|
574
|
+
"firmware": self.smile_fw_version,
|
575
|
+
"location": self._home_location,
|
576
|
+
"mac_address": self.smile_mac_address,
|
577
|
+
"model": self.smile_model,
|
578
|
+
"name": self.smile_name,
|
579
|
+
"zigbee_mac_address": self.smile_zigbee_mac_address,
|
580
|
+
"vendor": "Plugwise",
|
581
|
+
}.items():
|
582
|
+
if value is not None:
|
583
|
+
gw_key = cast(ApplianceType, key)
|
584
|
+
self.gw_devices[self.gateway_id][gw_key] = value
|
585
|
+
self._count += 1
|
586
|
+
|
587
|
+
def _all_appliances(self) -> None:
|
588
|
+
"""Collect all appliances with relevant info."""
|
589
|
+
self._count = 0
|
590
|
+
self._all_locations()
|
591
|
+
|
592
|
+
if self._smile_legacy:
|
593
|
+
self._create_legacy_gateway()
|
594
|
+
# For legacy P1 collect the connected SmartMeter info
|
595
|
+
if self.smile_type == "power":
|
596
|
+
appl = Munch()
|
597
|
+
self._p1_smartmeter_info_finder(appl)
|
598
|
+
# Legacy P1 has no more devices
|
599
|
+
return
|
600
|
+
|
601
|
+
for appliance in self._appliances.findall("./appliance"):
|
602
|
+
appl = Munch()
|
603
|
+
appl.pwclass = appliance.find("type").text
|
604
|
+
# Skip thermostats that have this key, should be an orphaned device (Core #81712)
|
605
|
+
if (
|
606
|
+
appl.pwclass == "thermostat"
|
607
|
+
and appliance.find("actuator_functionalities/") is None
|
608
|
+
):
|
609
|
+
continue
|
610
|
+
|
611
|
+
appl.location = None
|
612
|
+
if (appl_loc := appliance.find("location")) is not None:
|
613
|
+
appl.location = appl_loc.attrib["id"]
|
614
|
+
# Provide a location for legacy_anna, also don't assign the _home_location
|
615
|
+
# to thermostat-devices without a location, they are not active
|
616
|
+
elif (
|
617
|
+
self._smile_legacy and self.smile_type == "thermostat"
|
618
|
+
) or appl.pwclass not in THERMOSTAT_CLASSES:
|
619
|
+
appl.location = self._home_location
|
620
|
+
|
621
|
+
appl.dev_id = appliance.attrib["id"]
|
622
|
+
appl.name = appliance.find("name").text
|
623
|
+
appl.model = appl.pwclass.replace("_", " ").title()
|
624
|
+
appl.firmware = None
|
625
|
+
appl.hardware = None
|
626
|
+
appl.mac = None
|
627
|
+
appl.zigbee_mac = None
|
628
|
+
appl.vendor_name = None
|
629
|
+
|
630
|
+
# Determine class for this appliance
|
631
|
+
# Skip on heater_central when no active device present or on orphaned stretch devices
|
632
|
+
if not (appl := self._appliance_info_finder(appliance, appl)):
|
633
|
+
continue
|
634
|
+
|
635
|
+
# Skip orphaned heater_central (Core Issue #104433)
|
636
|
+
if appl.pwclass == "heater_central" and appl.dev_id != self._heater_id:
|
637
|
+
continue
|
638
|
+
|
639
|
+
# P1: for gateway and smartmeter switch device_id - part 1
|
640
|
+
# This is done to avoid breakage in HA Core
|
641
|
+
if appl.pwclass == "gateway" and self.smile_type == "power":
|
642
|
+
appl.dev_id = appl.location
|
643
|
+
|
644
|
+
# Don't show orphaned non-legacy thermostat-types or the OpenTherm Gateway.
|
645
|
+
if (
|
646
|
+
not self._smile_legacy
|
647
|
+
and appl.pwclass in THERMOSTAT_CLASSES
|
648
|
+
and appl.location is None
|
649
|
+
):
|
650
|
+
continue
|
651
|
+
|
652
|
+
self.gw_devices[appl.dev_id] = {"dev_class": appl.pwclass}
|
653
|
+
self._count += 1
|
654
|
+
for key, value in {
|
655
|
+
"firmware": appl.firmware,
|
656
|
+
"hardware": appl.hardware,
|
657
|
+
"location": appl.location,
|
658
|
+
"mac_address": appl.mac,
|
659
|
+
"model": appl.model,
|
660
|
+
"name": appl.name,
|
661
|
+
"zigbee_mac_address": appl.zigbee_mac,
|
662
|
+
"vendor": appl.vendor_name,
|
663
|
+
}.items():
|
664
|
+
if value is not None or key == "location":
|
665
|
+
appl_key = cast(ApplianceType, key)
|
666
|
+
self.gw_devices[appl.dev_id][appl_key] = value
|
667
|
+
self._count += 1
|
668
|
+
|
669
|
+
# For non-legacy P1 collect the connected SmartMeter info
|
670
|
+
if self.smile_type == "power":
|
671
|
+
self._p1_smartmeter_info_finder(appl)
|
672
|
+
# P1: for gateway and smartmeter switch device_id - part 2
|
673
|
+
for item in self.gw_devices:
|
674
|
+
if item != self.gateway_id:
|
675
|
+
self.gateway_id = item
|
676
|
+
# Leave for-loop to avoid a 2nd device_id switch
|
677
|
+
break
|
678
|
+
|
679
|
+
# Place the gateway and optional heater_central devices as 1st and 2nd
|
680
|
+
for dev_class in ("heater_central", "gateway"):
|
681
|
+
for dev_id, device in dict(self.gw_devices).items():
|
682
|
+
if device["dev_class"] == dev_class:
|
683
|
+
tmp_device = device
|
684
|
+
self.gw_devices.pop(dev_id)
|
685
|
+
cleared_dict = self.gw_devices
|
686
|
+
add_to_front = {dev_id: tmp_device}
|
687
|
+
self.gw_devices = {**add_to_front, **cleared_dict}
|
688
|
+
|
689
|
+
def _match_locations(self) -> dict[str, ThermoLoc]:
|
690
|
+
"""Helper-function for _scan_thermostats().
|
691
|
+
|
692
|
+
Match appliances with locations.
|
693
|
+
"""
|
694
|
+
matched_locations: dict[str, ThermoLoc] = {}
|
695
|
+
for location_id, location_details in self._loc_data.items():
|
696
|
+
for appliance_details in self.gw_devices.values():
|
697
|
+
if appliance_details["location"] == location_id:
|
698
|
+
location_details.update(
|
699
|
+
{"master": None, "master_prio": 0, "slaves": set()}
|
700
|
+
)
|
701
|
+
matched_locations[location_id] = location_details
|
702
|
+
|
703
|
+
return matched_locations
|
704
|
+
|
705
|
+
def _control_state(self, loc_id: str) -> str | bool:
|
706
|
+
"""Helper-function for _device_data_adam().
|
707
|
+
|
708
|
+
Adam: find the thermostat control_state of a location, from DOMAIN_OBJECTS.
|
709
|
+
Represents the heating/cooling demand-state of the local master thermostat.
|
710
|
+
Note: heating or cooling can still be active when the setpoint has been reached.
|
711
|
+
"""
|
712
|
+
locator = f'location[@id="{loc_id}"]'
|
713
|
+
if (location := self._domain_objects.find(locator)) is not None:
|
714
|
+
locator = './actuator_functionalities/thermostat_functionality[type="thermostat"]/control_state'
|
715
|
+
if (ctrl_state := location.find(locator)) is not None:
|
716
|
+
return str(ctrl_state.text)
|
717
|
+
|
718
|
+
return False
|
719
|
+
|
720
|
+
def _presets_legacy(self) -> dict[str, list[float]]:
|
721
|
+
"""Helper-function for presets() - collect Presets for a legacy Anna."""
|
722
|
+
presets: dict[str, list[float]] = {}
|
723
|
+
for directive in self._domain_objects.findall("rule/directives/when/then"):
|
724
|
+
if directive is not None and directive.get("icon") is not None:
|
725
|
+
# Ensure list of heating_setpoint, cooling_setpoint
|
726
|
+
presets[directive.attrib["icon"]] = [
|
727
|
+
float(directive.attrib["temperature"]),
|
728
|
+
0,
|
729
|
+
]
|
730
|
+
|
731
|
+
return presets
|
732
|
+
|
733
|
+
def _presets(self, loc_id: str) -> dict[str, list[float]]:
|
734
|
+
"""Collect Presets for a Thermostat based on location_id."""
|
735
|
+
presets: dict[str, list[float]] = {}
|
736
|
+
tag_1 = "zone_setpoint_and_state_based_on_preset"
|
737
|
+
tag_2 = "Thermostat presets"
|
738
|
+
|
739
|
+
if self._smile_legacy:
|
740
|
+
return self._presets_legacy()
|
741
|
+
|
742
|
+
if not (rule_ids := self._rule_ids_by_tag(tag_1, loc_id)):
|
743
|
+
if not (rule_ids := self._rule_ids_by_name(tag_2, loc_id)):
|
744
|
+
return presets # pragma: no cover
|
745
|
+
|
746
|
+
for rule_id in rule_ids:
|
747
|
+
directives: etree = self._domain_objects.find(
|
748
|
+
f'rule[@id="{rule_id}"]/directives'
|
749
|
+
)
|
750
|
+
for directive in directives:
|
751
|
+
preset = directive.find("then").attrib
|
752
|
+
presets[directive.attrib["preset"]] = [
|
753
|
+
float(preset["heating_setpoint"]),
|
754
|
+
float(preset["cooling_setpoint"]),
|
755
|
+
]
|
756
|
+
|
757
|
+
return presets
|
758
|
+
|
759
|
+
def _rule_ids_by_name(self, name: str, loc_id: str) -> dict[str, str]:
|
760
|
+
"""Helper-function for _presets().
|
761
|
+
|
762
|
+
Obtain the rule_id from the given name and and provide the location_id, when present.
|
763
|
+
"""
|
764
|
+
schedule_ids: dict[str, str] = {}
|
765
|
+
locator = f'./contexts/context/zone/location[@id="{loc_id}"]'
|
766
|
+
for rule in self._domain_objects.findall(f'./rule[name="{name}"]'):
|
767
|
+
if rule.find(locator) is not None:
|
768
|
+
schedule_ids[rule.attrib["id"]] = loc_id
|
769
|
+
else:
|
770
|
+
schedule_ids[rule.attrib["id"]] = NONE
|
771
|
+
|
772
|
+
return schedule_ids
|
773
|
+
|
774
|
+
def _rule_ids_by_tag(self, tag: str, loc_id: str) -> dict[str, str]:
|
775
|
+
"""Helper-function for _presets(), _schedules() and _last_active_schedule().
|
776
|
+
|
777
|
+
Obtain the rule_id from the given template_tag and provide the location_id, when present.
|
778
|
+
"""
|
779
|
+
schedule_ids: dict[str, str] = {}
|
780
|
+
locator1 = f'./template[@tag="{tag}"]'
|
781
|
+
locator2 = f'./contexts/context/zone/location[@id="{loc_id}"]'
|
782
|
+
for rule in self._domain_objects.findall("./rule"):
|
783
|
+
if rule.find(locator1) is not None:
|
784
|
+
if rule.find(locator2) is not None:
|
785
|
+
schedule_ids[rule.attrib["id"]] = loc_id
|
786
|
+
else:
|
787
|
+
schedule_ids[rule.attrib["id"]] = NONE
|
788
|
+
|
789
|
+
return schedule_ids
|
790
|
+
|
791
|
+
def _appliance_measurements(
|
792
|
+
self,
|
793
|
+
appliance: etree,
|
794
|
+
data: DeviceData,
|
795
|
+
measurements: dict[str, DATA | UOM],
|
796
|
+
) -> None:
|
797
|
+
"""Helper-function for _get_measurement_data() - collect appliance measurement data."""
|
798
|
+
for measurement, attrs in measurements.items():
|
799
|
+
p_locator = f'.//logs/point_log[type="{measurement}"]/period/measurement'
|
800
|
+
if (appl_p_loc := appliance.find(p_locator)) is not None:
|
801
|
+
if self._smile_legacy and measurement == "domestic_hot_water_state":
|
802
|
+
continue
|
803
|
+
|
804
|
+
# Skip known obsolete measurements
|
805
|
+
updated_date_locator = (
|
806
|
+
f'.//logs/point_log[type="{measurement}"]/updated_date'
|
807
|
+
)
|
808
|
+
if measurement in OBSOLETE_MEASUREMENTS:
|
809
|
+
if (
|
810
|
+
updated_date_key := appliance.find(updated_date_locator)
|
811
|
+
) is not None:
|
812
|
+
updated_date = updated_date_key.text.split("T")[0]
|
813
|
+
date_1 = dt.datetime.strptime(updated_date, "%Y-%m-%d")
|
814
|
+
date_2 = dt.datetime.now()
|
815
|
+
if int((date_2 - date_1).days) > 7:
|
816
|
+
continue
|
817
|
+
|
818
|
+
if new_name := getattr(attrs, ATTR_NAME, None):
|
819
|
+
measurement = new_name
|
820
|
+
|
821
|
+
match measurement:
|
822
|
+
# measurements with states "on" or "off" that need to be passed directly
|
823
|
+
case "select_dhw_mode":
|
824
|
+
data["select_dhw_mode"] = appl_p_loc.text
|
825
|
+
case _ as measurement if measurement in BINARY_SENSORS:
|
826
|
+
bs_key = cast(BinarySensorType, measurement)
|
827
|
+
bs_value = appl_p_loc.text in ["on", "true"]
|
828
|
+
data["binary_sensors"][bs_key] = bs_value
|
829
|
+
case _ as measurement if measurement in SENSORS:
|
830
|
+
s_key = cast(SensorType, measurement)
|
831
|
+
s_value = format_measure(
|
832
|
+
appl_p_loc.text, getattr(attrs, ATTR_UNIT_OF_MEASUREMENT)
|
833
|
+
)
|
834
|
+
data["sensors"][s_key] = s_value
|
835
|
+
# Anna: save cooling-related measurements for later use
|
836
|
+
# Use the local outdoor temperature as reference for turning cooling on/off
|
837
|
+
if measurement == "cooling_activation_outdoor_temperature":
|
838
|
+
self._cooling_activation_outdoor_temp = data["sensors"][
|
839
|
+
"cooling_activation_outdoor_temperature"
|
840
|
+
]
|
841
|
+
if measurement == "cooling_deactivation_threshold":
|
842
|
+
self._cooling_deactivation_threshold = data["sensors"][
|
843
|
+
"cooling_deactivation_threshold"
|
844
|
+
]
|
845
|
+
if measurement == "outdoor_air_temperature":
|
846
|
+
self._outdoor_temp = data["sensors"][
|
847
|
+
"outdoor_air_temperature"
|
848
|
+
]
|
849
|
+
case _ as measurement if measurement in SWITCHES:
|
850
|
+
sw_key = cast(SwitchType, measurement)
|
851
|
+
sw_value = appl_p_loc.text in ["on", "true"]
|
852
|
+
data["switches"][sw_key] = sw_value
|
853
|
+
case "c_heating_state":
|
854
|
+
value = appl_p_loc.text in ["on", "true"]
|
855
|
+
data["c_heating_state"] = value
|
856
|
+
case "elga_status_code":
|
857
|
+
data["elga_status_code"] = int(appl_p_loc.text)
|
858
|
+
|
859
|
+
i_locator = f'.//logs/interval_log[type="{measurement}"]/period/measurement'
|
860
|
+
if (appl_i_loc := appliance.find(i_locator)) is not None:
|
861
|
+
name = cast(SensorType, f"{measurement}_interval")
|
862
|
+
data["sensors"][name] = format_measure(
|
863
|
+
appl_i_loc.text, ENERGY_WATT_HOUR
|
864
|
+
)
|
865
|
+
|
866
|
+
self._count += len(data["binary_sensors"])
|
867
|
+
self._count += len(data["sensors"])
|
868
|
+
self._count += len(data["switches"])
|
869
|
+
# Don't count the above top-level dicts, only the remaining single items
|
870
|
+
self._count += len(data) - 3
|
871
|
+
|
872
|
+
def _wireless_availablity(self, appliance: etree, data: DeviceData) -> None:
|
873
|
+
"""Helper-function for _get_measurement_data().
|
874
|
+
|
875
|
+
Collect the availablity-status for wireless connected devices.
|
876
|
+
"""
|
877
|
+
if self.smile(ADAM):
|
878
|
+
# Collect for Plugs
|
879
|
+
locator = "./logs/interval_log/electricity_interval_meter"
|
880
|
+
mod_type = "electricity_interval_meter"
|
881
|
+
module_data = self._get_module_data(appliance, locator, mod_type)
|
882
|
+
if module_data["reachable"] is None:
|
883
|
+
# Collect for wireless thermostats
|
884
|
+
locator = "./logs/point_log[type='thermostat']/thermostat"
|
885
|
+
mod_type = "thermostat"
|
886
|
+
module_data = self._get_module_data(appliance, locator, mod_type)
|
887
|
+
|
888
|
+
if module_data["reachable"] is not None:
|
889
|
+
data["available"] = module_data["reachable"]
|
890
|
+
self._count += 1
|
891
|
+
|
892
|
+
def _get_appliances_with_offset_functionality(self) -> list[str]:
|
893
|
+
"""Helper-function collecting all appliance that have offset_functionality."""
|
894
|
+
therm_list: list[str] = []
|
895
|
+
offset_appls = self._appliances.findall(
|
896
|
+
'.//actuator_functionalities/offset_functionality[type="temperature_offset"]/offset/../../..'
|
897
|
+
)
|
898
|
+
for item in offset_appls:
|
899
|
+
therm_list.append(item.attrib["id"])
|
900
|
+
|
901
|
+
return therm_list
|
902
|
+
|
903
|
+
def _get_actuator_functionalities(
|
904
|
+
self, xml: etree, device: DeviceData, data: DeviceData
|
905
|
+
) -> None:
|
906
|
+
"""Helper-function for _get_measurement_data()."""
|
907
|
+
for item in ACTIVE_ACTUATORS:
|
908
|
+
# Skip max_dhw_temperature, not initially valid,
|
909
|
+
# skip thermostat for thermo_sensors
|
910
|
+
if item == "max_dhw_temperature" or (
|
911
|
+
item == "thermostat" and device["dev_class"] == "thermo_sensor"
|
912
|
+
):
|
913
|
+
continue
|
914
|
+
|
915
|
+
temp_dict: ActuatorData = {}
|
916
|
+
functionality = "thermostat_functionality"
|
917
|
+
if item == "temperature_offset":
|
918
|
+
functionality = "offset_functionality"
|
919
|
+
# Don't support temperature_offset for legacy Anna
|
920
|
+
if self._smile_legacy:
|
921
|
+
continue
|
922
|
+
|
923
|
+
# When there is no updated_date-text, skip the actuator
|
924
|
+
updated_date_location = f'.//actuator_functionalities/{functionality}[type="{item}"]/updated_date'
|
925
|
+
if (
|
926
|
+
updated_date_key := xml.find(updated_date_location)
|
927
|
+
) is not None and updated_date_key.text is None:
|
928
|
+
continue
|
929
|
+
|
930
|
+
for key in LIMITS:
|
931
|
+
locator = (
|
932
|
+
f'.//actuator_functionalities/{functionality}[type="{item}"]/{key}'
|
933
|
+
)
|
934
|
+
if (function := xml.find(locator)) is not None:
|
935
|
+
if key == "offset":
|
936
|
+
# Add limits and resolution for temperature_offset,
|
937
|
+
# not provided by Plugwise in the XML data
|
938
|
+
temp_dict["lower_bound"] = -2.0
|
939
|
+
temp_dict["resolution"] = 0.1
|
940
|
+
temp_dict["upper_bound"] = 2.0
|
941
|
+
self._count += 3
|
942
|
+
# Rename offset to setpoint
|
943
|
+
key = "setpoint"
|
944
|
+
|
945
|
+
act_key = cast(ActuatorDataType, key)
|
946
|
+
temp_dict[act_key] = format_measure(function.text, TEMP_CELSIUS)
|
947
|
+
self._count += 1
|
948
|
+
|
949
|
+
if temp_dict:
|
950
|
+
# If domestic_hot_water_setpoint is present as actuator,
|
951
|
+
# rename and remove as sensor
|
952
|
+
if item == DHW_SETPOINT:
|
953
|
+
item = "max_dhw_temperature"
|
954
|
+
if DHW_SETPOINT in data["sensors"]:
|
955
|
+
data["sensors"].pop(DHW_SETPOINT)
|
956
|
+
self._count -= 1
|
957
|
+
|
958
|
+
act_item = cast(ActuatorType, item)
|
959
|
+
data[act_item] = temp_dict
|
960
|
+
|
961
|
+
def _get_regulation_mode(self, appliance: etree, data: DeviceData) -> None:
|
962
|
+
"""Helper-function for _get_measurement_data().
|
963
|
+
|
964
|
+
Collect the gateway regulation_mode.
|
965
|
+
"""
|
966
|
+
locator = "./actuator_functionalities/regulation_mode_control_functionality"
|
967
|
+
if (search := appliance.find(locator)) is not None:
|
968
|
+
data["select_regulation_mode"] = search.find("mode").text
|
969
|
+
self._count += 1
|
970
|
+
self._cooling_enabled = data["select_regulation_mode"] == "cooling"
|
971
|
+
|
972
|
+
def _cleanup_data(self, data: DeviceData) -> None:
|
973
|
+
"""Helper-function for _get_measurement_data().
|
974
|
+
|
975
|
+
Clean up the data dict.
|
976
|
+
"""
|
977
|
+
# Don't show cooling-related when no cooling present,
|
978
|
+
# but, keep cooling_enabled for Elga
|
979
|
+
if not self._cooling_present:
|
980
|
+
if "cooling_state" in data["binary_sensors"]:
|
981
|
+
data["binary_sensors"].pop("cooling_state")
|
982
|
+
self._count -= 1
|
983
|
+
if "cooling_ena_switch" in data["switches"]:
|
984
|
+
data["switches"].pop("cooling_ena_switch") # pragma: no cover
|
985
|
+
self._count -= 1 # pragma: no cover
|
986
|
+
if not self._elga and "cooling_enabled" in data:
|
987
|
+
data.pop("cooling_enabled") # pragma: no cover
|
988
|
+
self._count -= 1 # pragma: no cover
|
989
|
+
|
990
|
+
def _process_c_heating_state(self, data: DeviceData) -> None:
|
991
|
+
"""Helper-function for _get_measurement_data().
|
992
|
+
|
993
|
+
Process the central_heating_state value.
|
994
|
+
"""
|
995
|
+
if self._on_off_device:
|
996
|
+
# Anna + OnOff heater: use central_heating_state to show heating_state
|
997
|
+
# Solution for Core issue #81839
|
998
|
+
if self.smile(ANNA):
|
999
|
+
data["binary_sensors"]["heating_state"] = data["c_heating_state"]
|
1000
|
+
|
1001
|
+
# Adam + OnOff cooling: use central_heating_state to show heating/cooling_state
|
1002
|
+
if self.smile(ADAM):
|
1003
|
+
if "heating_state" not in data["binary_sensors"]:
|
1004
|
+
self._count += 1
|
1005
|
+
data["binary_sensors"]["heating_state"] = False
|
1006
|
+
if "cooling_state" not in data["binary_sensors"]:
|
1007
|
+
self._count += 1
|
1008
|
+
data["binary_sensors"]["cooling_state"] = False
|
1009
|
+
if self._cooling_enabled:
|
1010
|
+
data["binary_sensors"]["cooling_state"] = data["c_heating_state"]
|
1011
|
+
else:
|
1012
|
+
data["binary_sensors"]["heating_state"] = data["c_heating_state"]
|
1013
|
+
|
1014
|
+
# Anna + Elga: use central_heating_state to show heating_state
|
1015
|
+
if self._elga:
|
1016
|
+
data["binary_sensors"]["heating_state"] = data["c_heating_state"]
|
1017
|
+
|
1018
|
+
def _get_measurement_data(self, dev_id: str) -> DeviceData:
|
1019
|
+
"""Helper-function for smile.py: _get_device_data().
|
1020
|
+
|
1021
|
+
Collect the appliance-data based on device id.
|
1022
|
+
"""
|
1023
|
+
data: DeviceData = {"binary_sensors": {}, "sensors": {}, "switches": {}}
|
1024
|
+
# Get P1 smartmeter data from LOCATIONS or MODULES
|
1025
|
+
device = self.gw_devices[dev_id]
|
1026
|
+
# !! DON'T CHANGE below two if-lines, will break stuff !!
|
1027
|
+
if self.smile_type == "power":
|
1028
|
+
if device["dev_class"] == "smartmeter":
|
1029
|
+
if not self._smile_legacy:
|
1030
|
+
data.update(self._power_data_from_location(device["location"]))
|
1031
|
+
else:
|
1032
|
+
data.update(self._power_data_from_modules())
|
1033
|
+
|
1034
|
+
return data
|
1035
|
+
|
1036
|
+
# Get non-p1 data from APPLIANCES, for legacy from DOMAIN_OBJECTS.
|
1037
|
+
measurements = DEVICE_MEASUREMENTS
|
1038
|
+
if self._is_thermostat and dev_id == self._heater_id:
|
1039
|
+
measurements = HEATER_CENTRAL_MEASUREMENTS
|
1040
|
+
# Show the allowed dhw_modes (Loria only)
|
1041
|
+
if self._dhw_allowed_modes:
|
1042
|
+
data["dhw_modes"] = self._dhw_allowed_modes
|
1043
|
+
# Counting of this item is done in _appliance_measurements()
|
1044
|
+
|
1045
|
+
if (
|
1046
|
+
appliance := self._appliances.find(f'./appliance[@id="{dev_id}"]')
|
1047
|
+
) is not None:
|
1048
|
+
self._appliance_measurements(appliance, data, measurements)
|
1049
|
+
self._get_lock_state(appliance, data)
|
1050
|
+
|
1051
|
+
for toggle, name in TOGGLES.items():
|
1052
|
+
self._get_toggle_state(appliance, toggle, name, data)
|
1053
|
+
|
1054
|
+
if appliance.find("type").text in ACTUATOR_CLASSES:
|
1055
|
+
self._get_actuator_functionalities(appliance, device, data)
|
1056
|
+
|
1057
|
+
# Collect availability-status for wireless connected devices to Adam
|
1058
|
+
self._wireless_availablity(appliance, data)
|
1059
|
+
|
1060
|
+
if dev_id == self.gateway_id and self.smile(ADAM):
|
1061
|
+
self._get_regulation_mode(appliance, data)
|
1062
|
+
|
1063
|
+
# Adam & Anna: the Smile outdoor_temperature is present in DOMAIN_OBJECTS and LOCATIONS - under Home
|
1064
|
+
# The outdoor_temperature present in APPLIANCES is a local sensor connected to the active device
|
1065
|
+
if self._is_thermostat and dev_id == self.gateway_id:
|
1066
|
+
outdoor_temperature = self._object_value(
|
1067
|
+
self._home_location, "outdoor_temperature"
|
1068
|
+
)
|
1069
|
+
if outdoor_temperature is not None:
|
1070
|
+
data.update({"sensors": {"outdoor_temperature": outdoor_temperature}})
|
1071
|
+
self._count += 1
|
1072
|
+
|
1073
|
+
if "c_heating_state" in data:
|
1074
|
+
self._process_c_heating_state(data)
|
1075
|
+
# Remove c_heating_state after processing
|
1076
|
+
data.pop("c_heating_state")
|
1077
|
+
self._count -= 1
|
1078
|
+
|
1079
|
+
if self._is_thermostat and self.smile(ANNA) and dev_id == self._heater_id:
|
1080
|
+
# Anna+Elga: base cooling_state on the elga-status-code
|
1081
|
+
if "elga_status_code" in data:
|
1082
|
+
# Techneco Elga has cooling-capability
|
1083
|
+
self._cooling_present = True
|
1084
|
+
data["model"] = "Generic heater/cooler"
|
1085
|
+
self._cooling_enabled = data["elga_status_code"] in [8, 9]
|
1086
|
+
data["binary_sensors"]["cooling_state"] = self._cooling_active = (
|
1087
|
+
data["elga_status_code"] == 8
|
1088
|
+
)
|
1089
|
+
data.pop("elga_status_code", None)
|
1090
|
+
self._count -= 1
|
1091
|
+
# Elga has no cooling-switch
|
1092
|
+
if "cooling_ena_switch" in data["switches"]:
|
1093
|
+
data["switches"].pop("cooling_ena_switch")
|
1094
|
+
self._count -= 1
|
1095
|
+
|
1096
|
+
# Loria/Thermastage: cooling-related is based on cooling_state
|
1097
|
+
# and modulation_level
|
1098
|
+
elif self._cooling_present and "cooling_state" in data["binary_sensors"]:
|
1099
|
+
self._cooling_enabled = data["binary_sensors"]["cooling_state"]
|
1100
|
+
self._cooling_active = data["sensors"]["modulation_level"] == 100
|
1101
|
+
# For Loria the above does not work (pw-beta issue #301)
|
1102
|
+
if "cooling_ena_switch" in data["switches"]:
|
1103
|
+
self._cooling_enabled = data["switches"]["cooling_ena_switch"]
|
1104
|
+
self._cooling_active = data["binary_sensors"]["cooling_state"]
|
1105
|
+
|
1106
|
+
self._cleanup_data(data)
|
1107
|
+
|
1108
|
+
return data
|
1109
|
+
|
1110
|
+
def _rank_thermostat(
|
1111
|
+
self,
|
1112
|
+
thermo_matching: dict[str, int],
|
1113
|
+
loc_id: str,
|
1114
|
+
appliance_id: str,
|
1115
|
+
appliance_details: DeviceData,
|
1116
|
+
) -> None:
|
1117
|
+
"""Helper-function for _scan_thermostats().
|
1118
|
+
|
1119
|
+
Rank the thermostat based on appliance_details: master or slave.
|
1120
|
+
"""
|
1121
|
+
appl_class = appliance_details["dev_class"]
|
1122
|
+
appl_d_loc = appliance_details["location"]
|
1123
|
+
if loc_id == appl_d_loc and appl_class in thermo_matching:
|
1124
|
+
# Pre-elect new master
|
1125
|
+
if thermo_matching[appl_class] > self._thermo_locs[loc_id]["master_prio"]:
|
1126
|
+
# Demote former master
|
1127
|
+
if (tl_master := self._thermo_locs[loc_id]["master"]) is not None:
|
1128
|
+
self._thermo_locs[loc_id]["slaves"].add(tl_master)
|
1129
|
+
|
1130
|
+
# Crown master
|
1131
|
+
self._thermo_locs[loc_id]["master_prio"] = thermo_matching[appl_class]
|
1132
|
+
self._thermo_locs[loc_id]["master"] = appliance_id
|
1133
|
+
|
1134
|
+
else:
|
1135
|
+
self._thermo_locs[loc_id]["slaves"].add(appliance_id)
|
1136
|
+
|
1137
|
+
def _scan_thermostats(self) -> None:
|
1138
|
+
"""Helper-function for smile.py: get_all_devices().
|
1139
|
+
|
1140
|
+
Update locations with thermostat ranking results and use
|
1141
|
+
the result to update the device_class of slave thermostats.
|
1142
|
+
"""
|
1143
|
+
self._thermo_locs = self._match_locations()
|
1144
|
+
|
1145
|
+
thermo_matching: dict[str, int] = {
|
1146
|
+
"thermostat": 3,
|
1147
|
+
"zone_thermometer": 2,
|
1148
|
+
"zone_thermostat": 2,
|
1149
|
+
"thermostatic_radiator_valve": 1,
|
1150
|
+
}
|
1151
|
+
|
1152
|
+
for loc_id in self._thermo_locs:
|
1153
|
+
for dev_id, device in self.gw_devices.items():
|
1154
|
+
self._rank_thermostat(thermo_matching, loc_id, dev_id, device)
|
1155
|
+
|
1156
|
+
# Update slave thermostat class where needed
|
1157
|
+
for dev_id, device in self.gw_devices.items():
|
1158
|
+
if (loc_id := device["location"]) in self._thermo_locs:
|
1159
|
+
tl_loc_id = self._thermo_locs[loc_id]
|
1160
|
+
if "slaves" in tl_loc_id and dev_id in tl_loc_id["slaves"]:
|
1161
|
+
device["dev_class"] = "thermo_sensor"
|
1162
|
+
|
1163
|
+
def _thermostat_uri_legacy(self) -> str:
|
1164
|
+
"""Helper-function for _thermostat_uri().
|
1165
|
+
|
1166
|
+
Determine the location-set_temperature uri - from APPLIANCES.
|
1167
|
+
"""
|
1168
|
+
locator = "./appliance[type='thermostat']"
|
1169
|
+
appliance_id = self._appliances.find(locator).attrib["id"]
|
1170
|
+
|
1171
|
+
return f"{APPLIANCES};id={appliance_id}/thermostat"
|
1172
|
+
|
1173
|
+
def _thermostat_uri(self, loc_id: str) -> str:
|
1174
|
+
"""Helper-function for smile.py: set_temperature().
|
1175
|
+
|
1176
|
+
Determine the location-set_temperature uri - from LOCATIONS.
|
1177
|
+
"""
|
1178
|
+
if self._smile_legacy:
|
1179
|
+
return self._thermostat_uri_legacy()
|
1180
|
+
|
1181
|
+
locator = f'./location[@id="{loc_id}"]/actuator_functionalities/thermostat_functionality'
|
1182
|
+
thermostat_functionality_id = self._locations.find(locator).attrib["id"]
|
1183
|
+
|
1184
|
+
return f"{LOCATIONS};id={loc_id}/thermostat;id={thermostat_functionality_id}"
|
1185
|
+
|
1186
|
+
def _get_group_switches(self) -> dict[str, DeviceData]:
|
1187
|
+
"""Helper-function for smile.py: get_all_devices().
|
1188
|
+
|
1189
|
+
Collect switching- or pump-group info.
|
1190
|
+
"""
|
1191
|
+
switch_groups: dict[str, DeviceData] = {}
|
1192
|
+
# P1 and Anna don't have switchgroups
|
1193
|
+
if self.smile_type == "power" or self.smile(ANNA):
|
1194
|
+
return switch_groups
|
1195
|
+
|
1196
|
+
for group in self._domain_objects.findall("./group"):
|
1197
|
+
members: list[str] = []
|
1198
|
+
group_id = group.attrib["id"]
|
1199
|
+
group_name = group.find("name").text
|
1200
|
+
group_type = group.find("type").text
|
1201
|
+
group_appliances = group.findall("appliances/appliance")
|
1202
|
+
for item in group_appliances:
|
1203
|
+
# Check if members are not orphaned - stretch
|
1204
|
+
if item.attrib["id"] in self.gw_devices:
|
1205
|
+
members.append(item.attrib["id"])
|
1206
|
+
|
1207
|
+
if group_type in SWITCH_GROUP_TYPES and members:
|
1208
|
+
switch_groups.update(
|
1209
|
+
{
|
1210
|
+
group_id: {
|
1211
|
+
"dev_class": group_type,
|
1212
|
+
"model": "Switchgroup",
|
1213
|
+
"name": group_name,
|
1214
|
+
"members": members,
|
1215
|
+
},
|
1216
|
+
},
|
1217
|
+
)
|
1218
|
+
self._count += 4
|
1219
|
+
|
1220
|
+
return switch_groups
|
1221
|
+
|
1222
|
+
def _heating_valves(self) -> int | bool:
|
1223
|
+
"""Helper-function for smile.py: _device_data_adam().
|
1224
|
+
|
1225
|
+
Collect amount of open valves indicating active direct heating.
|
1226
|
+
For cases where the heat is provided from an external shared source (city heating).
|
1227
|
+
"""
|
1228
|
+
loc_found: int = 0
|
1229
|
+
open_valve_count: int = 0
|
1230
|
+
for appliance in self._appliances.findall("./appliance"):
|
1231
|
+
locator = './logs/point_log[type="valve_position"]/period/measurement'
|
1232
|
+
if (appl_loc := appliance.find(locator)) is not None:
|
1233
|
+
loc_found += 1
|
1234
|
+
if float(appl_loc.text) > 0.0:
|
1235
|
+
open_valve_count += 1
|
1236
|
+
|
1237
|
+
return False if loc_found == 0 else open_valve_count
|
1238
|
+
|
1239
|
+
def power_data_energy_diff(
|
1240
|
+
self,
|
1241
|
+
measurement: str,
|
1242
|
+
net_string: SensorType,
|
1243
|
+
f_val: float | int,
|
1244
|
+
direct_data: DeviceData,
|
1245
|
+
) -> DeviceData:
|
1246
|
+
"""Calculate differential energy."""
|
1247
|
+
if (
|
1248
|
+
"electricity" in measurement
|
1249
|
+
and "phase" not in measurement
|
1250
|
+
and "interval" not in net_string
|
1251
|
+
):
|
1252
|
+
diff = 1
|
1253
|
+
if "produced" in measurement:
|
1254
|
+
diff = -1
|
1255
|
+
if net_string not in direct_data["sensors"]:
|
1256
|
+
tmp_val: float | int = 0
|
1257
|
+
else:
|
1258
|
+
tmp_val = direct_data["sensors"][net_string]
|
1259
|
+
|
1260
|
+
if isinstance(f_val, int):
|
1261
|
+
tmp_val += f_val * diff
|
1262
|
+
else:
|
1263
|
+
tmp_val += float(f_val * diff)
|
1264
|
+
tmp_val = float(f"{round(tmp_val, 3):.3f}")
|
1265
|
+
|
1266
|
+
direct_data["sensors"][net_string] = tmp_val
|
1267
|
+
|
1268
|
+
return direct_data
|
1269
|
+
|
1270
|
+
def _power_data_peak_value(self, direct_data: DeviceData, loc: Munch) -> Munch:
|
1271
|
+
"""Helper-function for _power_data_from_location() and _power_data_from_modules()."""
|
1272
|
+
loc.found = True
|
1273
|
+
# If locator not found look for P1 gas_consumed or phase data (without tariff)
|
1274
|
+
# or for P1 legacy electricity_point_meter or gas_*_meter data
|
1275
|
+
if loc.logs.find(loc.locator) is None:
|
1276
|
+
if "log" in loc.log_type and (
|
1277
|
+
"gas" in loc.measurement or "phase" in loc.measurement
|
1278
|
+
):
|
1279
|
+
# Avoid double processing by skipping one peak-list option
|
1280
|
+
if loc.peak_select == "nl_offpeak":
|
1281
|
+
loc.found = False
|
1282
|
+
return loc
|
1283
|
+
|
1284
|
+
loc.locator = (
|
1285
|
+
f'./{loc.log_type}[type="{loc.measurement}"]/period/measurement'
|
1286
|
+
)
|
1287
|
+
if loc.logs.find(loc.locator) is None:
|
1288
|
+
loc.found = False
|
1289
|
+
return loc
|
1290
|
+
# P1 legacy point_meter has no tariff_indicator
|
1291
|
+
elif "meter" in loc.log_type and (
|
1292
|
+
"point" in loc.log_type or "gas" in loc.measurement
|
1293
|
+
):
|
1294
|
+
# Avoid double processing by skipping one peak-list option
|
1295
|
+
if loc.peak_select == "nl_offpeak":
|
1296
|
+
loc.found = False
|
1297
|
+
return loc
|
1298
|
+
|
1299
|
+
loc.locator = (
|
1300
|
+
f"./{loc.meas_list[0]}_{loc.log_type}/"
|
1301
|
+
f'measurement[@directionality="{loc.meas_list[1]}"]'
|
1302
|
+
)
|
1303
|
+
if loc.logs.find(loc.locator) is None:
|
1304
|
+
loc.found = False
|
1305
|
+
return loc
|
1306
|
+
else:
|
1307
|
+
loc.found = False
|
1308
|
+
return loc
|
1309
|
+
|
1310
|
+
if (peak := loc.peak_select.split("_")[1]) == "offpeak":
|
1311
|
+
peak = "off_peak"
|
1312
|
+
log_found = loc.log_type.split("_")[0]
|
1313
|
+
loc.key_string = f"{loc.measurement}_{peak}_{log_found}"
|
1314
|
+
if "gas" in loc.measurement or loc.log_type == "point_meter":
|
1315
|
+
loc.key_string = f"{loc.measurement}_{log_found}"
|
1316
|
+
if "phase" in loc.measurement:
|
1317
|
+
loc.key_string = f"{loc.measurement}"
|
1318
|
+
loc.net_string = f"net_electricity_{log_found}"
|
1319
|
+
val = loc.logs.find(loc.locator).text
|
1320
|
+
loc.f_val = power_data_local_format(loc.attrs, loc.key_string, val)
|
1321
|
+
|
1322
|
+
return loc
|
1323
|
+
|
1324
|
+
def _power_data_from_location(self, loc_id: str) -> DeviceData:
|
1325
|
+
"""Helper-function for smile.py: _get_device_data().
|
1326
|
+
|
1327
|
+
Collect the power-data based on Location ID, from LOCATIONS.
|
1328
|
+
"""
|
1329
|
+
direct_data: DeviceData = {"sensors": {}}
|
1330
|
+
loc = Munch()
|
1331
|
+
log_list: list[str] = ["point_log", "cumulative_log", "interval_log"]
|
1332
|
+
peak_list: list[str] = ["nl_peak", "nl_offpeak"]
|
1333
|
+
t_string = "tariff"
|
1334
|
+
|
1335
|
+
search = self._locations
|
1336
|
+
loc.logs = search.find(f'./location[@id="{loc_id}"]/logs')
|
1337
|
+
for loc.measurement, loc.attrs in P1_MEASUREMENTS.items():
|
1338
|
+
for loc.log_type in log_list:
|
1339
|
+
for loc.peak_select in peak_list:
|
1340
|
+
# meter_string = ".//{}[type='{}']/"
|
1341
|
+
loc.locator = (
|
1342
|
+
f'./{loc.log_type}[type="{loc.measurement}"]/period/'
|
1343
|
+
f'measurement[@{t_string}="{loc.peak_select}"]'
|
1344
|
+
)
|
1345
|
+
loc = self._power_data_peak_value(direct_data, loc)
|
1346
|
+
if not loc.found:
|
1347
|
+
continue
|
1348
|
+
|
1349
|
+
direct_data = self.power_data_energy_diff(
|
1350
|
+
loc.measurement, loc.net_string, loc.f_val, direct_data
|
1351
|
+
)
|
1352
|
+
key = cast(SensorType, loc.key_string)
|
1353
|
+
direct_data["sensors"][key] = loc.f_val
|
1354
|
+
|
1355
|
+
self._count += len(direct_data["sensors"])
|
1356
|
+
return direct_data
|
1357
|
+
|
1358
|
+
def _power_data_from_modules(self) -> DeviceData:
|
1359
|
+
"""Helper-function for smile.py: _get_device_data().
|
1360
|
+
|
1361
|
+
Collect the power-data from MODULES (P1 legacy only).
|
1362
|
+
"""
|
1363
|
+
direct_data: DeviceData = {"sensors": {}}
|
1364
|
+
loc = Munch()
|
1365
|
+
mod_list: list[str] = ["interval_meter", "cumulative_meter", "point_meter"]
|
1366
|
+
peak_list: list[str] = ["nl_peak", "nl_offpeak"]
|
1367
|
+
t_string = "tariff_indicator"
|
1368
|
+
|
1369
|
+
search = self._modules
|
1370
|
+
mod_logs = search.findall("./module/services")
|
1371
|
+
for loc.measurement, loc.attrs in P1_LEGACY_MEASUREMENTS.items():
|
1372
|
+
loc.meas_list = loc.measurement.split("_")
|
1373
|
+
for loc.logs in mod_logs:
|
1374
|
+
for loc.log_type in mod_list:
|
1375
|
+
for loc.peak_select in peak_list:
|
1376
|
+
loc.locator = (
|
1377
|
+
f"./{loc.meas_list[0]}_{loc.log_type}/measurement"
|
1378
|
+
f'[@directionality="{loc.meas_list[1]}"][@{t_string}="{loc.peak_select}"]'
|
1379
|
+
)
|
1380
|
+
loc = self._power_data_peak_value(direct_data, loc)
|
1381
|
+
if not loc.found:
|
1382
|
+
continue
|
1383
|
+
|
1384
|
+
direct_data = self.power_data_energy_diff(
|
1385
|
+
loc.measurement, loc.net_string, loc.f_val, direct_data
|
1386
|
+
)
|
1387
|
+
key = cast(SensorType, loc.key_string)
|
1388
|
+
direct_data["sensors"][key] = loc.f_val
|
1389
|
+
|
1390
|
+
self._count += len(direct_data["sensors"])
|
1391
|
+
return direct_data
|
1392
|
+
|
1393
|
+
def _preset(self, loc_id: str) -> str | None:
|
1394
|
+
"""Helper-function for smile.py: device_data_climate().
|
1395
|
+
|
1396
|
+
Collect the active preset based on Location ID.
|
1397
|
+
"""
|
1398
|
+
if not self._smile_legacy:
|
1399
|
+
locator = f'./location[@id="{loc_id}"]/preset'
|
1400
|
+
if (preset := self._domain_objects.find(locator)) is not None:
|
1401
|
+
return str(preset.text)
|
1402
|
+
return None
|
1403
|
+
|
1404
|
+
locator = "./rule[active='true']/directives/when/then"
|
1405
|
+
if (
|
1406
|
+
not (active_rule := etree_to_dict(self._domain_objects.find(locator)))
|
1407
|
+
or "icon" not in active_rule
|
1408
|
+
):
|
1409
|
+
return None
|
1410
|
+
|
1411
|
+
return active_rule["icon"]
|
1412
|
+
|
1413
|
+
def _schedules_legacy(
|
1414
|
+
self,
|
1415
|
+
avail: list[str],
|
1416
|
+
location: str,
|
1417
|
+
sel: str,
|
1418
|
+
) -> tuple[list[str], str]:
|
1419
|
+
"""Helper-function for _schedules().
|
1420
|
+
|
1421
|
+
Collect available schedules/schedules for the legacy thermostat.
|
1422
|
+
"""
|
1423
|
+
name: str | None = None
|
1424
|
+
|
1425
|
+
search = self._domain_objects
|
1426
|
+
for schedule in search.findall("./rule"):
|
1427
|
+
if rule_name := schedule.find("name").text:
|
1428
|
+
if "preset" not in rule_name:
|
1429
|
+
name = rule_name
|
1430
|
+
|
1431
|
+
log_type = "schedule_state"
|
1432
|
+
locator = f"./appliance[type='thermostat']/logs/point_log[type='{log_type}']/period/measurement"
|
1433
|
+
active = False
|
1434
|
+
if (result := search.find(locator)) is not None:
|
1435
|
+
active = result.text == "on"
|
1436
|
+
|
1437
|
+
if name is not None:
|
1438
|
+
avail = [name]
|
1439
|
+
if active:
|
1440
|
+
sel = name
|
1441
|
+
|
1442
|
+
self._last_active[location] = "".join(map(str, avail))
|
1443
|
+
return avail, sel
|
1444
|
+
|
1445
|
+
def _schedules(self, location: str) -> tuple[list[str], str]:
|
1446
|
+
"""Helper-function for smile.py: _device_data_climate().
|
1447
|
+
|
1448
|
+
Obtain the available schedules/schedules. Adam: a schedule can be connected to more than one location.
|
1449
|
+
NEW: when a location_id is present then the schedule is active. Valid for both Adam and non-legacy Anna.
|
1450
|
+
"""
|
1451
|
+
available: list[str] = [NONE]
|
1452
|
+
rule_ids: dict[str, str] = {}
|
1453
|
+
selected = NONE
|
1454
|
+
|
1455
|
+
# Legacy Anna schedule, only one schedule allowed
|
1456
|
+
if self._smile_legacy:
|
1457
|
+
return self._schedules_legacy(available, location, selected)
|
1458
|
+
|
1459
|
+
# Adam schedules, one schedule can be linked to various locations
|
1460
|
+
# self._last_active contains the locations and the active schedule name per location, or None
|
1461
|
+
if location not in self._last_active:
|
1462
|
+
self._last_active[location] = None
|
1463
|
+
|
1464
|
+
tag = "zone_preset_based_on_time_and_presence_with_override"
|
1465
|
+
if not (rule_ids := self._rule_ids_by_tag(tag, location)):
|
1466
|
+
return available, selected
|
1467
|
+
|
1468
|
+
schedules: list[str] = []
|
1469
|
+
for rule_id, loc_id in rule_ids.items():
|
1470
|
+
name = self._domain_objects.find(f'./rule[@id="{rule_id}"]/name').text
|
1471
|
+
locator = f'./rule[@id="{rule_id}"]/directives'
|
1472
|
+
# Show an empty schedule as no schedule found
|
1473
|
+
if not self._domain_objects.find(locator):
|
1474
|
+
continue # pragma: no cover
|
1475
|
+
|
1476
|
+
available.append(name)
|
1477
|
+
if location == loc_id:
|
1478
|
+
selected = name
|
1479
|
+
self._last_active[location] = selected
|
1480
|
+
schedules.append(name)
|
1481
|
+
|
1482
|
+
if schedules:
|
1483
|
+
available.remove(NONE)
|
1484
|
+
available.append(OFF)
|
1485
|
+
if self._last_active.get(location) is None:
|
1486
|
+
self._last_active[location] = self._last_used_schedule(schedules)
|
1487
|
+
|
1488
|
+
return available, selected
|
1489
|
+
|
1490
|
+
def _last_used_schedule(self, schedules: list[str]) -> str:
|
1491
|
+
"""Helper-function for _schedules().
|
1492
|
+
|
1493
|
+
Determine the last-used schedule based on the modified date.
|
1494
|
+
"""
|
1495
|
+
epoch = dt.datetime(1970, 1, 1, tzinfo=tz.tzutc())
|
1496
|
+
schedules_dates: dict[str, float] = {}
|
1497
|
+
|
1498
|
+
for name in schedules:
|
1499
|
+
result = self._domain_objects.find(f'./rule[name="{name}"]')
|
1500
|
+
schedule_date = result.find("modified_date").text
|
1501
|
+
schedule_time = parse(schedule_date)
|
1502
|
+
schedules_dates[name] = (schedule_time - epoch).total_seconds()
|
1503
|
+
|
1504
|
+
return sorted(schedules_dates.items(), key=lambda kv: kv[1])[-1][0]
|
1505
|
+
|
1506
|
+
def _object_value(self, obj_id: str, measurement: str) -> float | int | None:
|
1507
|
+
"""Helper-function for smile.py: _get_device_data() and _device_data_anna().
|
1508
|
+
|
1509
|
+
Obtain the value/state for the given object from a location in DOMAIN_OBJECTS
|
1510
|
+
"""
|
1511
|
+
val: float | int | None = None
|
1512
|
+
search = self._domain_objects
|
1513
|
+
locator = f'./location[@id="{obj_id}"]/logs/point_log[type="{measurement}"]/period/measurement'
|
1514
|
+
if (found := search.find(locator)) is not None:
|
1515
|
+
val = format_measure(found.text, NONE)
|
1516
|
+
return val
|
1517
|
+
|
1518
|
+
return val
|
1519
|
+
|
1520
|
+
def _get_lock_state(self, xml: etree, data: DeviceData) -> None:
|
1521
|
+
"""Helper-function for _get_measurement_data().
|
1522
|
+
|
1523
|
+
Adam & Stretches: obtain the relay-switch lock state.
|
1524
|
+
"""
|
1525
|
+
actuator = "actuator_functionalities"
|
1526
|
+
func_type = "relay_functionality"
|
1527
|
+
if self._stretch_v2:
|
1528
|
+
actuator = "actuators"
|
1529
|
+
func_type = "relay"
|
1530
|
+
if xml.find("type").text not in SPECIAL_PLUG_TYPES:
|
1531
|
+
locator = f"./{actuator}/{func_type}/lock"
|
1532
|
+
if (found := xml.find(locator)) is not None:
|
1533
|
+
data["switches"]["lock"] = found.text == "true"
|
1534
|
+
self._count += 1
|
1535
|
+
|
1536
|
+
def _get_toggle_state(
|
1537
|
+
self, xml: etree, toggle: str, name: ToggleNameType, data: DeviceData
|
1538
|
+
) -> None:
|
1539
|
+
"""Helper-function for _get_measurement_data().
|
1540
|
+
|
1541
|
+
Obtain the toggle state of a 'toggle' = switch.
|
1542
|
+
"""
|
1543
|
+
if xml.find("type").text == "heater_central":
|
1544
|
+
locator = f"./actuator_functionalities/toggle_functionality[type='{toggle}']/state"
|
1545
|
+
if (state := xml.find(locator)) is not None:
|
1546
|
+
data["switches"][name] = state.text == "on"
|
1547
|
+
self._count += 1
|
1548
|
+
# Remove the cooling_enabled binary_sensor when the corresponding switch is present
|
1549
|
+
# Except for Elga
|
1550
|
+
if toggle == "cooling_enabled" and not self._elga:
|
1551
|
+
data["binary_sensors"].pop("cooling_enabled")
|
1552
|
+
self._count -= 1
|