plugwise 0.37.1a2__py3-none-any.whl → 0.37.3__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/common.py +194 -16
- plugwise/constants.py +47 -44
- plugwise/data.py +57 -73
- plugwise/helper.py +430 -614
- plugwise/legacy/data.py +28 -44
- plugwise/legacy/helper.py +174 -357
- plugwise/legacy/smile.py +45 -41
- plugwise/smile.py +130 -130
- plugwise/util.py +62 -0
- {plugwise-0.37.1a2.dist-info → plugwise-0.37.3.dist-info}/METADATA +1 -5
- plugwise-0.37.3.dist-info/RECORD +17 -0
- {plugwise-0.37.1a2.dist-info → plugwise-0.37.3.dist-info}/WHEEL +1 -1
- plugwise-0.37.1a2.dist-info/RECORD +0 -17
- {plugwise-0.37.1a2.dist-info → plugwise-0.37.3.dist-info}/LICENSE +0 -0
- {plugwise-0.37.1a2.dist-info → plugwise-0.37.3.dist-info}/top_level.txt +0 -0
plugwise/helper.py
CHANGED
@@ -27,13 +27,10 @@ from plugwise.constants import (
|
|
27
27
|
LOCATIONS,
|
28
28
|
LOGGER,
|
29
29
|
NONE,
|
30
|
-
OBSOLETE_MEASUREMENTS,
|
31
30
|
OFF,
|
32
31
|
P1_MEASUREMENTS,
|
33
32
|
SENSORS,
|
34
|
-
SPECIAL_PLUG_TYPES,
|
35
33
|
SPECIALS,
|
36
|
-
SWITCH_GROUP_TYPES,
|
37
34
|
SWITCHES,
|
38
35
|
TEMP_CELSIUS,
|
39
36
|
THERMOSTAT_CLASSES,
|
@@ -42,7 +39,6 @@ from plugwise.constants import (
|
|
42
39
|
ActuatorData,
|
43
40
|
ActuatorDataType,
|
44
41
|
ActuatorType,
|
45
|
-
ApplianceType,
|
46
42
|
BinarySensorType,
|
47
43
|
DeviceData,
|
48
44
|
GatewayData,
|
@@ -62,7 +58,7 @@ from plugwise.util import (
|
|
62
58
|
check_model,
|
63
59
|
escape_illegal_xml_characters,
|
64
60
|
format_measure,
|
65
|
-
|
61
|
+
skip_obsolete_measurements,
|
66
62
|
)
|
67
63
|
|
68
64
|
# This way of importing aiohttp is because of patch/mocking in testing (aiohttp timeouts)
|
@@ -112,34 +108,6 @@ class SmileComm:
|
|
112
108
|
self._endpoint = f"http://{host}:{str(port)}"
|
113
109
|
self._timeout = timeout
|
114
110
|
|
115
|
-
async def _request_validate(self, resp: ClientResponse, method: str) -> etree:
|
116
|
-
"""Helper-function for _request(): validate the returned data."""
|
117
|
-
# Command accepted gives empty body with status 202
|
118
|
-
if resp.status == 202:
|
119
|
-
return
|
120
|
-
|
121
|
-
# Cornercase for stretch not responding with 202
|
122
|
-
if method == "put" and resp.status == 200:
|
123
|
-
return
|
124
|
-
|
125
|
-
if resp.status == 401:
|
126
|
-
msg = "Invalid Plugwise login, please retry with the correct credentials."
|
127
|
-
LOGGER.error("%s", msg)
|
128
|
-
raise InvalidAuthentication
|
129
|
-
|
130
|
-
if not (result := await resp.text()) or "<error>" in result:
|
131
|
-
LOGGER.warning("Smile response empty or error in %s", result)
|
132
|
-
raise ResponseError
|
133
|
-
|
134
|
-
try:
|
135
|
-
# Encode to ensure utf8 parsing
|
136
|
-
xml = etree.XML(escape_illegal_xml_characters(result).encode())
|
137
|
-
except etree.ParseError as exc:
|
138
|
-
LOGGER.warning("Smile returns invalid XML for %s", self._endpoint)
|
139
|
-
raise InvalidXMLError from exc
|
140
|
-
|
141
|
-
return xml
|
142
|
-
|
143
111
|
async def _request(
|
144
112
|
self,
|
145
113
|
command: str,
|
@@ -185,6 +153,34 @@ class SmileComm:
|
|
185
153
|
|
186
154
|
return await self._request_validate(resp, method)
|
187
155
|
|
156
|
+
async def _request_validate(self, resp: ClientResponse, method: str) -> etree:
|
157
|
+
"""Helper-function for _request(): validate the returned data."""
|
158
|
+
# Command accepted gives empty body with status 202
|
159
|
+
if resp.status == 202:
|
160
|
+
return
|
161
|
+
|
162
|
+
# Cornercase for stretch not responding with 202
|
163
|
+
if method == "put" and resp.status == 200:
|
164
|
+
return
|
165
|
+
|
166
|
+
if resp.status == 401:
|
167
|
+
msg = "Invalid Plugwise login, please retry with the correct credentials."
|
168
|
+
LOGGER.error("%s", msg)
|
169
|
+
raise InvalidAuthentication
|
170
|
+
|
171
|
+
if not (result := await resp.text()) or "<error>" in result:
|
172
|
+
LOGGER.warning("Smile response empty or error in %s", result)
|
173
|
+
raise ResponseError
|
174
|
+
|
175
|
+
try:
|
176
|
+
# Encode to ensure utf8 parsing
|
177
|
+
xml = etree.XML(escape_illegal_xml_characters(result).encode())
|
178
|
+
except etree.ParseError as exc:
|
179
|
+
LOGGER.warning("Smile returns invalid XML for %s", self._endpoint)
|
180
|
+
raise InvalidXMLError from exc
|
181
|
+
|
182
|
+
return xml
|
183
|
+
|
188
184
|
async def close_connection(self) -> None:
|
189
185
|
"""Close the Plugwise connection."""
|
190
186
|
await self._websession.close()
|
@@ -195,8 +191,6 @@ class SmileHelper(SmileCommon):
|
|
195
191
|
|
196
192
|
def __init__(self) -> None:
|
197
193
|
"""Set the constructor for this class."""
|
198
|
-
self._cooling_activation_outdoor_temp: float
|
199
|
-
self._cooling_deactivation_threshold: float
|
200
194
|
self._cooling_present: bool
|
201
195
|
self._count: int
|
202
196
|
self._dhw_allowed_modes: list[str] = []
|
@@ -212,7 +206,6 @@ class SmileHelper(SmileCommon):
|
|
212
206
|
self._notifications: dict[str, dict[str, str]] = {}
|
213
207
|
self._on_off_device: bool
|
214
208
|
self._opentherm_device: bool
|
215
|
-
self._outdoor_temp: float
|
216
209
|
self._reg_allowed_modes: list[str] = []
|
217
210
|
self._schedule_old_states: dict[str, dict[str, str]]
|
218
211
|
self._status: etree
|
@@ -248,144 +241,6 @@ class SmileHelper(SmileCommon):
|
|
248
241
|
self.therms_with_offset_func: list[str] = []
|
249
242
|
SmileCommon.__init__(self)
|
250
243
|
|
251
|
-
def _all_locations(self) -> None:
|
252
|
-
"""Collect all locations."""
|
253
|
-
loc = Munch()
|
254
|
-
locations = self._domain_objects.findall("./location")
|
255
|
-
for location in locations:
|
256
|
-
loc.name = location.find("name").text
|
257
|
-
loc.loc_id = location.attrib["id"]
|
258
|
-
if loc.name == "Home":
|
259
|
-
self._home_location = loc.loc_id
|
260
|
-
|
261
|
-
self.loc_data[loc.loc_id] = {"name": loc.name}
|
262
|
-
|
263
|
-
def _energy_device_info_finder(self, appliance: etree, appl: Munch) -> Munch:
|
264
|
-
"""Helper-function for _appliance_info_finder().
|
265
|
-
|
266
|
-
Collect energy device info (Smartmeter, Plug): firmware, model and vendor name.
|
267
|
-
"""
|
268
|
-
if self.smile_type == "power":
|
269
|
-
locator = "./logs/point_log/electricity_point_meter"
|
270
|
-
mod_type = "electricity_point_meter"
|
271
|
-
module_data = self._get_module_data(appliance, locator, mod_type)
|
272
|
-
appl.hardware = module_data["hardware_version"]
|
273
|
-
appl.model = module_data["vendor_model"]
|
274
|
-
appl.vendor_name = module_data["vendor_name"]
|
275
|
-
appl.firmware = module_data["firmware_version"]
|
276
|
-
|
277
|
-
return appl
|
278
|
-
|
279
|
-
if self.smile(ADAM):
|
280
|
-
locator = "./logs/interval_log/electricity_interval_meter"
|
281
|
-
mod_type = "electricity_interval_meter"
|
282
|
-
module_data = self._get_module_data(appliance, locator, mod_type)
|
283
|
-
# Filter appliance without zigbee_mac, it's an orphaned device
|
284
|
-
appl.zigbee_mac = module_data["zigbee_mac_address"]
|
285
|
-
if appl.zigbee_mac is None:
|
286
|
-
return None
|
287
|
-
|
288
|
-
appl.vendor_name = module_data["vendor_name"]
|
289
|
-
appl.model = check_model(module_data["vendor_model"], appl.vendor_name)
|
290
|
-
appl.hardware = module_data["hardware_version"]
|
291
|
-
appl.firmware = module_data["firmware_version"]
|
292
|
-
|
293
|
-
return appl
|
294
|
-
|
295
|
-
return appl # pragma: no cover
|
296
|
-
|
297
|
-
def _appliance_info_finder(self, appl: Munch, appliance: etree) -> Munch:
|
298
|
-
"""Collect device info (Smile/Stretch, Thermostats, OpenTherm/On-Off): firmware, model and vendor name."""
|
299
|
-
# Collect gateway device info
|
300
|
-
if appl.pwclass == "gateway":
|
301
|
-
self.gateway_id = appliance.attrib["id"]
|
302
|
-
appl.firmware = self.smile_fw_version
|
303
|
-
appl.hardware = self.smile_hw_version
|
304
|
-
appl.mac = self.smile_mac_address
|
305
|
-
appl.model = self.smile_model
|
306
|
-
appl.name = self.smile_name
|
307
|
-
appl.vendor_name = "Plugwise"
|
308
|
-
|
309
|
-
# Adam: look for the ZigBee MAC address of the Smile
|
310
|
-
if self.smile(ADAM) and (
|
311
|
-
(found := self._domain_objects.find(".//protocols/zig_bee_coordinator")) is not None
|
312
|
-
):
|
313
|
-
appl.zigbee_mac = found.find("mac_address").text
|
314
|
-
|
315
|
-
# Adam: collect regulation_modes and check for cooling, indicating cooling-mode is present
|
316
|
-
reg_mode_list: list[str] = []
|
317
|
-
locator = "./actuator_functionalities/regulation_mode_control_functionality"
|
318
|
-
if (search := appliance.find(locator)) is not None:
|
319
|
-
if search.find("allowed_modes") is not None:
|
320
|
-
for mode in search.find("allowed_modes"):
|
321
|
-
reg_mode_list.append(mode.text)
|
322
|
-
if mode.text == "cooling":
|
323
|
-
self._cooling_present = True
|
324
|
-
self._reg_allowed_modes = reg_mode_list
|
325
|
-
|
326
|
-
# Adam: check for presence of gateway_modes
|
327
|
-
self._gw_allowed_modes = []
|
328
|
-
locator = "./actuator_functionalities/gateway_mode_control_functionality[type='gateway_mode']/allowed_modes"
|
329
|
-
if appliance.find(locator) is not None:
|
330
|
-
# Limit the possible gateway-modes
|
331
|
-
self._gw_allowed_modes = ["away", "full", "vacation"]
|
332
|
-
|
333
|
-
return appl
|
334
|
-
|
335
|
-
# Collect thermostat device info
|
336
|
-
if appl.pwclass in THERMOSTAT_CLASSES:
|
337
|
-
return self._appl_thermostat_info(appl, appliance)
|
338
|
-
|
339
|
-
# Collect extra heater_central device info
|
340
|
-
if appl.pwclass == "heater_central":
|
341
|
-
appl = self._appl_heater_central_info(appl, appliance)
|
342
|
-
# Anna + Loria: collect dhw control operation modes
|
343
|
-
dhw_mode_list: list[str] = []
|
344
|
-
locator = "./actuator_functionalities/domestic_hot_water_mode_control_functionality"
|
345
|
-
if (search := appliance.find(locator)) is not None:
|
346
|
-
if search.find("allowed_modes") is not None:
|
347
|
-
for mode in search.find("allowed_modes"):
|
348
|
-
dhw_mode_list.append(mode.text)
|
349
|
-
self._dhw_allowed_modes = dhw_mode_list
|
350
|
-
|
351
|
-
return appl
|
352
|
-
|
353
|
-
# Collect info from power-related devices (Plug, Aqara Smart Plug)
|
354
|
-
appl = self._energy_device_info_finder(appliance, appl)
|
355
|
-
|
356
|
-
return appl
|
357
|
-
|
358
|
-
def _p1_smartmeter_info_finder(self, appl: Munch) -> None:
|
359
|
-
"""Collect P1 DSMR Smartmeter info."""
|
360
|
-
loc_id = next(iter(self.loc_data.keys()))
|
361
|
-
appl.dev_id = self.gateway_id
|
362
|
-
appl.location = loc_id
|
363
|
-
appl.mac = None
|
364
|
-
appl.model = self.smile_model
|
365
|
-
appl.name = "P1"
|
366
|
-
appl.pwclass = "smartmeter"
|
367
|
-
appl.zigbee_mac = None
|
368
|
-
location = self._domain_objects.find(f'./location[@id="{loc_id}"]')
|
369
|
-
appl = self._energy_device_info_finder(location, appl)
|
370
|
-
|
371
|
-
self.gw_devices[appl.dev_id] = {"dev_class": appl.pwclass}
|
372
|
-
self._count += 1
|
373
|
-
|
374
|
-
for key, value in {
|
375
|
-
"firmware": appl.firmware,
|
376
|
-
"hardware": appl.hardware,
|
377
|
-
"location": appl.location,
|
378
|
-
"mac_address": appl.mac,
|
379
|
-
"model": appl.model,
|
380
|
-
"name": appl.name,
|
381
|
-
"zigbee_mac_address": appl.zigbee_mac,
|
382
|
-
"vendor": appl.vendor_name,
|
383
|
-
}.items():
|
384
|
-
if value is not None or key == "location":
|
385
|
-
p1_key = cast(ApplianceType, key)
|
386
|
-
self.gw_devices[appl.dev_id][p1_key] = value
|
387
|
-
self._count += 1
|
388
|
-
|
389
244
|
def _all_appliances(self) -> None:
|
390
245
|
"""Collect all appliances with relevant info."""
|
391
246
|
self._count = 0
|
@@ -436,22 +291,7 @@ class SmileHelper(SmileCommon):
|
|
436
291
|
if appl.pwclass in THERMOSTAT_CLASSES and appl.location is None:
|
437
292
|
continue
|
438
293
|
|
439
|
-
self.
|
440
|
-
self._count += 1
|
441
|
-
for key, value in {
|
442
|
-
"firmware": appl.firmware,
|
443
|
-
"hardware": appl.hardware,
|
444
|
-
"location": appl.location,
|
445
|
-
"mac_address": appl.mac,
|
446
|
-
"model": appl.model,
|
447
|
-
"name": appl.name,
|
448
|
-
"zigbee_mac_address": appl.zigbee_mac,
|
449
|
-
"vendor": appl.vendor_name,
|
450
|
-
}.items():
|
451
|
-
if value is not None or key == "location":
|
452
|
-
appl_key = cast(ApplianceType, key)
|
453
|
-
self.gw_devices[appl.dev_id][appl_key] = value
|
454
|
-
self._count += 1
|
294
|
+
self._create_gw_devices(appl)
|
455
295
|
|
456
296
|
# For P1 collect the connected SmartMeter info
|
457
297
|
if self.smile_type == "power":
|
@@ -473,80 +313,263 @@ class SmileHelper(SmileCommon):
|
|
473
313
|
add_to_front = {dev_id: tmp_device}
|
474
314
|
self.gw_devices = {**add_to_front, **cleared_dict}
|
475
315
|
|
476
|
-
def
|
477
|
-
"""
|
316
|
+
def _all_locations(self) -> None:
|
317
|
+
"""Collect all locations."""
|
318
|
+
loc = Munch()
|
319
|
+
locations = self._domain_objects.findall("./location")
|
320
|
+
for location in locations:
|
321
|
+
loc.name = location.find("name").text
|
322
|
+
loc.loc_id = location.attrib["id"]
|
323
|
+
if loc.name == "Home":
|
324
|
+
self._home_location = loc.loc_id
|
478
325
|
|
479
|
-
|
480
|
-
|
481
|
-
|
326
|
+
self.loc_data[loc.loc_id] = {"name": loc.name}
|
327
|
+
|
328
|
+
def _p1_smartmeter_info_finder(self, appl: Munch) -> None:
|
329
|
+
"""Collect P1 DSMR Smartmeter info."""
|
330
|
+
loc_id = next(iter(self.loc_data.keys()))
|
331
|
+
appl.dev_id = self.gateway_id
|
332
|
+
appl.location = loc_id
|
333
|
+
appl.mac = None
|
334
|
+
appl.model = self.smile_model
|
335
|
+
appl.name = "P1"
|
336
|
+
appl.pwclass = "smartmeter"
|
337
|
+
appl.zigbee_mac = None
|
338
|
+
location = self._domain_objects.find(f'./location[@id="{loc_id}"]')
|
339
|
+
appl = self._energy_device_info_finder(appl, location)
|
340
|
+
|
341
|
+
self._create_gw_devices(appl)
|
342
|
+
|
343
|
+
def _appliance_info_finder(self, appl: Munch, appliance: etree) -> Munch:
|
344
|
+
"""Collect device info (Smile/Stretch, Thermostats, OpenTherm/On-Off): firmware, model and vendor name."""
|
345
|
+
match appl.pwclass:
|
346
|
+
case "gateway":
|
347
|
+
# Collect gateway device info
|
348
|
+
return self._appl_gateway_info(appl, appliance)
|
349
|
+
case _ as dev_class if dev_class in THERMOSTAT_CLASSES:
|
350
|
+
# Collect thermostat device info
|
351
|
+
return self._appl_thermostat_info(appl, appliance)
|
352
|
+
case "heater_central":
|
353
|
+
# Collect heater_central device info
|
354
|
+
self._appl_heater_central_info(appl, appliance)
|
355
|
+
self._appl_dhw_mode_info(appl, appliance)
|
356
|
+
return appl
|
357
|
+
case _:
|
358
|
+
# Collect info from power-related devices (Plug, Aqara Smart Plug)
|
359
|
+
return self._energy_device_info_finder(appl, appliance)
|
360
|
+
|
361
|
+
def _energy_device_info_finder(self, appl: Munch, appliance: etree) -> Munch:
|
362
|
+
"""Helper-function for _appliance_info_finder().
|
363
|
+
|
364
|
+
Collect energy device info (Smartmeter, Plug): firmware, model and vendor name.
|
482
365
|
"""
|
483
|
-
|
484
|
-
|
485
|
-
|
486
|
-
|
487
|
-
|
366
|
+
if self.smile_type == "power":
|
367
|
+
locator = "./logs/point_log/electricity_point_meter"
|
368
|
+
mod_type = "electricity_point_meter"
|
369
|
+
module_data = self._get_module_data(appliance, locator, mod_type)
|
370
|
+
appl.hardware = module_data["hardware_version"]
|
371
|
+
appl.model = module_data["vendor_model"]
|
372
|
+
appl.vendor_name = module_data["vendor_name"]
|
373
|
+
appl.firmware = module_data["firmware_version"]
|
488
374
|
|
489
|
-
|
375
|
+
return appl
|
490
376
|
|
491
|
-
|
492
|
-
|
493
|
-
|
494
|
-
|
495
|
-
|
496
|
-
|
497
|
-
if
|
498
|
-
return
|
377
|
+
if self.smile(ADAM):
|
378
|
+
locator = "./logs/interval_log/electricity_interval_meter"
|
379
|
+
mod_type = "electricity_interval_meter"
|
380
|
+
module_data = self._get_module_data(appliance, locator, mod_type)
|
381
|
+
# Filter appliance without zigbee_mac, it's an orphaned device
|
382
|
+
appl.zigbee_mac = module_data["zigbee_mac_address"]
|
383
|
+
if appl.zigbee_mac is None:
|
384
|
+
return None
|
499
385
|
|
500
|
-
|
501
|
-
|
502
|
-
|
503
|
-
|
504
|
-
for directive in directives:
|
505
|
-
preset = directive.find("then").attrib
|
506
|
-
presets[directive.attrib["preset"]] = [
|
507
|
-
float(preset["heating_setpoint"]),
|
508
|
-
float(preset["cooling_setpoint"]),
|
509
|
-
]
|
386
|
+
appl.vendor_name = module_data["vendor_name"]
|
387
|
+
appl.model = check_model(module_data["vendor_model"], appl.vendor_name)
|
388
|
+
appl.hardware = module_data["hardware_version"]
|
389
|
+
appl.firmware = module_data["firmware_version"]
|
510
390
|
|
511
|
-
|
391
|
+
return appl
|
512
392
|
|
513
|
-
|
514
|
-
"""Helper-function for _presets().
|
393
|
+
return appl # pragma: no cover
|
515
394
|
|
516
|
-
|
517
|
-
"""
|
518
|
-
|
519
|
-
|
520
|
-
|
521
|
-
|
522
|
-
|
523
|
-
|
524
|
-
|
525
|
-
schedule_ids[rule.attrib["id"]] = {"location": NONE, "name": name, "active": active}
|
395
|
+
def _appl_gateway_info(self, appl: Munch, appliance: etree) -> Munch:
|
396
|
+
"""Helper-function for _appliance_info_finder()."""
|
397
|
+
self.gateway_id = appliance.attrib["id"]
|
398
|
+
appl.firmware = self.smile_fw_version
|
399
|
+
appl.hardware = self.smile_hw_version
|
400
|
+
appl.mac = self.smile_mac_address
|
401
|
+
appl.model = self.smile_model
|
402
|
+
appl.name = self.smile_name
|
403
|
+
appl.vendor_name = "Plugwise"
|
526
404
|
|
527
|
-
|
405
|
+
# Adam: collect the ZigBee MAC address of the Smile
|
406
|
+
if self.smile(ADAM):
|
407
|
+
if (found := self._domain_objects.find(".//protocols/zig_bee_coordinator")) is not None:
|
408
|
+
appl.zigbee_mac = found.find("mac_address").text
|
528
409
|
|
529
|
-
|
530
|
-
|
410
|
+
# Also, collect regulation_modes and check for cooling, indicating cooling-mode is present
|
411
|
+
self._appl_regulation_mode_info(appliance)
|
531
412
|
|
532
|
-
|
533
|
-
|
534
|
-
|
535
|
-
|
536
|
-
|
537
|
-
|
538
|
-
if rule.find(locator1) is not None:
|
539
|
-
name = rule.find("name").text
|
540
|
-
active = rule.find("active").text
|
541
|
-
if rule.find(locator2) is not None:
|
542
|
-
schedule_ids[rule.attrib["id"]] = {"location": loc_id, "name": name, "active": active}
|
543
|
-
else:
|
544
|
-
schedule_ids[rule.attrib["id"]] = {"location": NONE, "name": name, "active": active}
|
413
|
+
# Finally, collect the gateway_modes
|
414
|
+
self._gw_allowed_modes = []
|
415
|
+
locator = "./actuator_functionalities/gateway_mode_control_functionality[type='gateway_mode']/allowed_modes"
|
416
|
+
if appliance.find(locator) is not None:
|
417
|
+
# Limit the possible gateway-modes
|
418
|
+
self._gw_allowed_modes = ["away", "full", "vacation"]
|
545
419
|
|
546
|
-
return
|
420
|
+
return appl
|
547
421
|
|
548
|
-
def
|
549
|
-
|
422
|
+
def _appl_regulation_mode_info(self, appliance: etree) -> None:
|
423
|
+
"""Helper-function for _appliance_info_finder()."""
|
424
|
+
reg_mode_list: list[str] = []
|
425
|
+
locator = "./actuator_functionalities/regulation_mode_control_functionality"
|
426
|
+
if (search := appliance.find(locator)) is not None:
|
427
|
+
if search.find("allowed_modes") is not None:
|
428
|
+
for mode in search.find("allowed_modes"):
|
429
|
+
reg_mode_list.append(mode.text)
|
430
|
+
if mode.text == "cooling":
|
431
|
+
self._cooling_present = True
|
432
|
+
self._reg_allowed_modes = reg_mode_list
|
433
|
+
|
434
|
+
def _appl_dhw_mode_info(self, appl: Munch, appliance: etree) -> Munch:
|
435
|
+
"""Helper-function for _appliance_info_finder().
|
436
|
+
|
437
|
+
Collect dhw control operation modes - Anna + Loria.
|
438
|
+
"""
|
439
|
+
dhw_mode_list: list[str] = []
|
440
|
+
locator = "./actuator_functionalities/domestic_hot_water_mode_control_functionality"
|
441
|
+
if (search := appliance.find(locator)) is not None:
|
442
|
+
if search.find("allowed_modes") is not None:
|
443
|
+
for mode in search.find("allowed_modes"):
|
444
|
+
dhw_mode_list.append(mode.text)
|
445
|
+
self._dhw_allowed_modes = dhw_mode_list
|
446
|
+
|
447
|
+
return appl
|
448
|
+
|
449
|
+
def _get_appliances_with_offset_functionality(self) -> list[str]:
|
450
|
+
"""Helper-function collecting all appliance that have offset_functionality."""
|
451
|
+
therm_list: list[str] = []
|
452
|
+
offset_appls = self._domain_objects.findall(
|
453
|
+
'.//actuator_functionalities/offset_functionality[type="temperature_offset"]/offset/../../..'
|
454
|
+
)
|
455
|
+
for item in offset_appls:
|
456
|
+
therm_list.append(item.attrib["id"])
|
457
|
+
|
458
|
+
return therm_list
|
459
|
+
|
460
|
+
def _get_measurement_data(self, dev_id: str) -> DeviceData:
|
461
|
+
"""Helper-function for smile.py: _get_device_data().
|
462
|
+
|
463
|
+
Collect the appliance-data based on device id.
|
464
|
+
"""
|
465
|
+
data: DeviceData = {"binary_sensors": {}, "sensors": {}, "switches": {}}
|
466
|
+
# Get P1 smartmeter data from LOCATIONS
|
467
|
+
device = self.gw_devices[dev_id]
|
468
|
+
# !! DON'T CHANGE below two if-lines, will break stuff !!
|
469
|
+
if self.smile_type == "power":
|
470
|
+
if device["dev_class"] == "smartmeter":
|
471
|
+
data.update(self._power_data_from_location(device["location"]))
|
472
|
+
|
473
|
+
return data
|
474
|
+
|
475
|
+
# Get non-P1 data from APPLIANCES
|
476
|
+
measurements = DEVICE_MEASUREMENTS
|
477
|
+
if self._is_thermostat and dev_id == self._heater_id:
|
478
|
+
measurements = HEATER_CENTRAL_MEASUREMENTS
|
479
|
+
# Show the allowed dhw_modes (Loria only)
|
480
|
+
if self._dhw_allowed_modes:
|
481
|
+
data["dhw_modes"] = self._dhw_allowed_modes
|
482
|
+
# Counting of this item is done in _appliance_measurements()
|
483
|
+
|
484
|
+
if (
|
485
|
+
appliance := self._domain_objects.find(f'./appliance[@id="{dev_id}"]')
|
486
|
+
) is not None:
|
487
|
+
self._appliance_measurements(appliance, data, measurements)
|
488
|
+
self._get_lock_state(appliance, data)
|
489
|
+
|
490
|
+
for toggle, name in TOGGLES.items():
|
491
|
+
self._get_toggle_state(appliance, toggle, name, data)
|
492
|
+
|
493
|
+
if appliance.find("type").text in ACTUATOR_CLASSES:
|
494
|
+
self._get_actuator_functionalities(appliance, device, data)
|
495
|
+
|
496
|
+
# Collect availability-status for wireless connected devices to Adam
|
497
|
+
self._wireless_availability(appliance, data)
|
498
|
+
|
499
|
+
if dev_id == self.gateway_id and self.smile(ADAM):
|
500
|
+
self._get_regulation_mode(appliance, data)
|
501
|
+
self._get_gateway_mode(appliance, data)
|
502
|
+
|
503
|
+
# Adam & Anna: the Smile outdoor_temperature is present in DOMAIN_OBJECTS and LOCATIONS - under Home
|
504
|
+
# The outdoor_temperature present in APPLIANCES is a local sensor connected to the active device
|
505
|
+
if self._is_thermostat and dev_id == self.gateway_id:
|
506
|
+
outdoor_temperature = self._object_value(
|
507
|
+
self._home_location, "outdoor_temperature"
|
508
|
+
)
|
509
|
+
if outdoor_temperature is not None:
|
510
|
+
data.update({"sensors": {"outdoor_temperature": outdoor_temperature}})
|
511
|
+
self._count += 1
|
512
|
+
|
513
|
+
if "c_heating_state" in data:
|
514
|
+
self._process_c_heating_state(data)
|
515
|
+
# Remove c_heating_state after processing
|
516
|
+
data.pop("c_heating_state")
|
517
|
+
self._count -= 1
|
518
|
+
|
519
|
+
if self._is_thermostat and self.smile(ANNA) and dev_id == self._heater_id:
|
520
|
+
# Anna+Elga: base cooling_state on the elga-status-code
|
521
|
+
if "elga_status_code" in data:
|
522
|
+
if data["thermostat_supports_cooling"]:
|
523
|
+
# Techneco Elga has cooling-capability
|
524
|
+
self._cooling_present = True
|
525
|
+
data["model"] = "Generic heater/cooler"
|
526
|
+
self._cooling_enabled = data["elga_status_code"] in [8, 9]
|
527
|
+
data["binary_sensors"]["cooling_state"] = self._cooling_active = (
|
528
|
+
data["elga_status_code"] == 8
|
529
|
+
)
|
530
|
+
# Elga has no cooling-switch
|
531
|
+
if "cooling_ena_switch" in data["switches"]:
|
532
|
+
data["switches"].pop("cooling_ena_switch")
|
533
|
+
self._count -= 1
|
534
|
+
|
535
|
+
data.pop("elga_status_code", None)
|
536
|
+
self._count -= 1
|
537
|
+
|
538
|
+
# Loria/Thermastage: cooling-related is based on cooling_state
|
539
|
+
# and modulation_level
|
540
|
+
elif self._cooling_present and "cooling_state" in data["binary_sensors"]:
|
541
|
+
self._cooling_enabled = data["binary_sensors"]["cooling_state"]
|
542
|
+
self._cooling_active = data["sensors"]["modulation_level"] == 100
|
543
|
+
# For Loria the above does not work (pw-beta issue #301)
|
544
|
+
if "cooling_ena_switch" in data["switches"]:
|
545
|
+
self._cooling_enabled = data["switches"]["cooling_ena_switch"]
|
546
|
+
self._cooling_active = data["binary_sensors"]["cooling_state"]
|
547
|
+
|
548
|
+
self._cleanup_data(data)
|
549
|
+
|
550
|
+
return data
|
551
|
+
|
552
|
+
def _power_data_from_location(self, loc_id: str) -> DeviceData:
|
553
|
+
"""Helper-function for smile.py: _get_device_data().
|
554
|
+
|
555
|
+
Collect the power-data based on Location ID, from LOCATIONS.
|
556
|
+
"""
|
557
|
+
direct_data: DeviceData = {"sensors": {}}
|
558
|
+
loc = Munch()
|
559
|
+
log_list: list[str] = ["point_log", "cumulative_log", "interval_log"]
|
560
|
+
t_string = "tariff"
|
561
|
+
|
562
|
+
search = self._domain_objects
|
563
|
+
loc.logs = search.find(f'./location[@id="{loc_id}"]/logs')
|
564
|
+
for loc.measurement, loc.attrs in P1_MEASUREMENTS.items():
|
565
|
+
for loc.log_type in log_list:
|
566
|
+
self._collect_power_values(direct_data, loc, t_string)
|
567
|
+
|
568
|
+
self._count += len(direct_data["sensors"])
|
569
|
+
return direct_data
|
570
|
+
|
571
|
+
def _appliance_measurements(
|
572
|
+
self,
|
550
573
|
appliance: etree,
|
551
574
|
data: DeviceData,
|
552
575
|
measurements: dict[str, DATA | UOM],
|
@@ -555,20 +578,8 @@ class SmileHelper(SmileCommon):
|
|
555
578
|
for measurement, attrs in measurements.items():
|
556
579
|
p_locator = f'.//logs/point_log[type="{measurement}"]/period/measurement'
|
557
580
|
if (appl_p_loc := appliance.find(p_locator)) is not None:
|
558
|
-
|
559
|
-
|
560
|
-
f'.//logs/point_log[type="{measurement}"]/updated_date'
|
561
|
-
)
|
562
|
-
if (
|
563
|
-
measurement in OBSOLETE_MEASUREMENTS
|
564
|
-
and (updated_date_key := appliance.find(updated_date_locator))
|
565
|
-
is not None
|
566
|
-
):
|
567
|
-
updated_date = updated_date_key.text.split("T")[0]
|
568
|
-
date_1 = dt.datetime.strptime(updated_date, "%Y-%m-%d")
|
569
|
-
date_2 = dt.datetime.now()
|
570
|
-
if int((date_2 - date_1).days) > 7:
|
571
|
-
continue
|
581
|
+
if skip_obsolete_measurements(appliance, measurement):
|
582
|
+
continue
|
572
583
|
|
573
584
|
if new_name := getattr(attrs, ATTR_NAME, None):
|
574
585
|
measurement = new_name
|
@@ -587,20 +598,6 @@ class SmileHelper(SmileCommon):
|
|
587
598
|
appl_p_loc.text, getattr(attrs, ATTR_UNIT_OF_MEASUREMENT)
|
588
599
|
)
|
589
600
|
data["sensors"][s_key] = s_value
|
590
|
-
# Anna: save cooling-related measurements for later use
|
591
|
-
# Use the local outdoor temperature as reference for turning cooling on/off
|
592
|
-
if measurement == "cooling_activation_outdoor_temperature":
|
593
|
-
self._cooling_activation_outdoor_temp = data["sensors"][
|
594
|
-
"cooling_activation_outdoor_temperature"
|
595
|
-
]
|
596
|
-
if measurement == "cooling_deactivation_threshold":
|
597
|
-
self._cooling_deactivation_threshold = data["sensors"][
|
598
|
-
"cooling_deactivation_threshold"
|
599
|
-
]
|
600
|
-
if measurement == "outdoor_air_temperature":
|
601
|
-
self._outdoor_temp = data["sensors"][
|
602
|
-
"outdoor_air_temperature"
|
603
|
-
]
|
604
601
|
case _ as measurement if measurement in SWITCHES:
|
605
602
|
sw_key = cast(SwitchType, measurement)
|
606
603
|
sw_value = appl_p_loc.text in ["on", "true"]
|
@@ -625,36 +622,34 @@ class SmileHelper(SmileCommon):
|
|
625
622
|
# Don't count the above top-level dicts, only the remaining single items
|
626
623
|
self._count += len(data) - 3
|
627
624
|
|
628
|
-
def
|
625
|
+
def _get_toggle_state(
|
626
|
+
self, xml: etree, toggle: str, name: ToggleNameType, data: DeviceData
|
627
|
+
) -> None:
|
629
628
|
"""Helper-function for _get_measurement_data().
|
630
629
|
|
631
|
-
|
630
|
+
Obtain the toggle state of a 'toggle' = switch.
|
632
631
|
"""
|
633
|
-
if
|
634
|
-
|
635
|
-
locator
|
636
|
-
|
637
|
-
module_data = self._get_module_data(appliance, locator, mod_type)
|
638
|
-
if module_data["reachable"] is None:
|
639
|
-
# Collect for wireless thermostats
|
640
|
-
locator = "./logs/point_log[type='thermostat']/thermostat"
|
641
|
-
mod_type = "thermostat"
|
642
|
-
module_data = self._get_module_data(appliance, locator, mod_type)
|
643
|
-
|
644
|
-
if module_data["reachable"] is not None:
|
645
|
-
data["available"] = module_data["reachable"]
|
632
|
+
if xml.find("type").text == "heater_central":
|
633
|
+
locator = f"./actuator_functionalities/toggle_functionality[type='{toggle}']/state"
|
634
|
+
if (state := xml.find(locator)) is not None:
|
635
|
+
data["switches"][name] = state.text == "on"
|
646
636
|
self._count += 1
|
647
637
|
|
648
|
-
def
|
649
|
-
"""
|
650
|
-
|
651
|
-
|
652
|
-
|
653
|
-
|
654
|
-
|
655
|
-
|
656
|
-
|
657
|
-
|
638
|
+
def _get_plugwise_notifications(self) -> None:
|
639
|
+
"""Collect the Plugwise notifications."""
|
640
|
+
self._notifications = {}
|
641
|
+
for notification in self._domain_objects.findall("./notification"):
|
642
|
+
try:
|
643
|
+
msg_id = notification.attrib["id"]
|
644
|
+
msg_type = notification.find("type").text
|
645
|
+
msg = notification.find("message").text
|
646
|
+
self._notifications.update({msg_id: {msg_type: msg}})
|
647
|
+
LOGGER.debug("Plugwise notifications: %s", self._notifications)
|
648
|
+
except AttributeError: # pragma: no cover
|
649
|
+
LOGGER.debug(
|
650
|
+
"Plugwise notification present but unable to process, manually investigate: %s",
|
651
|
+
f"{self._endpoint}{DOMAIN_OBJECTS}",
|
652
|
+
)
|
658
653
|
|
659
654
|
def _get_actuator_functionalities(
|
660
655
|
self, xml: etree, device: DeviceData, data: DeviceData
|
@@ -710,6 +705,26 @@ class SmileHelper(SmileCommon):
|
|
710
705
|
act_item = cast(ActuatorType, item)
|
711
706
|
data[act_item] = temp_dict
|
712
707
|
|
708
|
+
def _wireless_availability(self, appliance: etree, data: DeviceData) -> None:
|
709
|
+
"""Helper-function for _get_measurement_data().
|
710
|
+
|
711
|
+
Collect the availability-status for wireless connected devices.
|
712
|
+
"""
|
713
|
+
if self.smile(ADAM):
|
714
|
+
# Collect for Plugs
|
715
|
+
locator = "./logs/interval_log/electricity_interval_meter"
|
716
|
+
mod_type = "electricity_interval_meter"
|
717
|
+
module_data = self._get_module_data(appliance, locator, mod_type)
|
718
|
+
if module_data["reachable"] is None:
|
719
|
+
# Collect for wireless thermostats
|
720
|
+
locator = "./logs/point_log[type='thermostat']/thermostat"
|
721
|
+
mod_type = "thermostat"
|
722
|
+
module_data = self._get_module_data(appliance, locator, mod_type)
|
723
|
+
|
724
|
+
if module_data["reachable"] is not None:
|
725
|
+
data["available"] = module_data["reachable"]
|
726
|
+
self._count += 1
|
727
|
+
|
713
728
|
def _get_regulation_mode(self, appliance: etree, data: DeviceData) -> None:
|
714
729
|
"""Helper-function for _get_measurement_data().
|
715
730
|
|
@@ -731,27 +746,18 @@ class SmileHelper(SmileCommon):
|
|
731
746
|
data["select_gateway_mode"] = search.find("mode").text
|
732
747
|
self._count += 1
|
733
748
|
|
734
|
-
def
|
735
|
-
"""Helper-function for
|
749
|
+
def _object_value(self, obj_id: str, measurement: str) -> float | int | None:
|
750
|
+
"""Helper-function for smile.py: _get_device_data() and _device_data_anna().
|
736
751
|
|
737
|
-
|
752
|
+
Obtain the value/state for the given object from a location in DOMAIN_OBJECTS
|
738
753
|
"""
|
739
|
-
|
740
|
-
|
741
|
-
|
742
|
-
|
743
|
-
|
744
|
-
self._count -= 1
|
745
|
-
if "cooling_ena_switch" in data["switches"]:
|
746
|
-
data["switches"].pop("cooling_ena_switch") # pragma: no cover
|
747
|
-
self._count -= 1 # pragma: no cover
|
748
|
-
if "cooling_enabled" in data["binary_sensors"]:
|
749
|
-
data["binary_sensors"].pop("cooling_enabled") # pragma: no cover
|
750
|
-
self._count -= 1 # pragma: no cover
|
754
|
+
val: float | int | None = None
|
755
|
+
search = self._domain_objects
|
756
|
+
locator = f'./location[@id="{obj_id}"]/logs/point_log[type="{measurement}"]/period/measurement'
|
757
|
+
if (found := search.find(locator)) is not None:
|
758
|
+
val = format_measure(found.text, NONE)
|
751
759
|
|
752
|
-
|
753
|
-
data.pop("thermostat_supports_cooling", None)
|
754
|
-
self._count -= 1
|
760
|
+
return val
|
755
761
|
|
756
762
|
def _process_c_heating_state(self, data: DeviceData) -> None:
|
757
763
|
"""Helper-function for _get_measurement_data().
|
@@ -781,103 +787,33 @@ class SmileHelper(SmileCommon):
|
|
781
787
|
if self._elga:
|
782
788
|
data["binary_sensors"]["heating_state"] = data["c_heating_state"]
|
783
789
|
|
784
|
-
def
|
785
|
-
"""Helper-function for
|
790
|
+
def _cleanup_data(self, data: DeviceData) -> None:
|
791
|
+
"""Helper-function for _get_measurement_data().
|
786
792
|
|
787
|
-
|
793
|
+
Clean up the data dict.
|
788
794
|
"""
|
789
|
-
|
790
|
-
#
|
791
|
-
|
792
|
-
|
793
|
-
|
794
|
-
if device["dev_class"] == "smartmeter":
|
795
|
-
data.update(self._power_data_from_location(device["location"]))
|
796
|
-
|
797
|
-
return data
|
798
|
-
|
799
|
-
# Get non-P1 data from APPLIANCES
|
800
|
-
measurements = DEVICE_MEASUREMENTS
|
801
|
-
if self._is_thermostat and dev_id == self._heater_id:
|
802
|
-
measurements = HEATER_CENTRAL_MEASUREMENTS
|
803
|
-
# Show the allowed dhw_modes (Loria only)
|
804
|
-
if self._dhw_allowed_modes:
|
805
|
-
data["dhw_modes"] = self._dhw_allowed_modes
|
806
|
-
# Counting of this item is done in _appliance_measurements()
|
807
|
-
|
808
|
-
if (
|
809
|
-
appliance := self._domain_objects.find(f'./appliance[@id="{dev_id}"]')
|
810
|
-
) is not None:
|
811
|
-
self._appliance_measurements(appliance, data, measurements)
|
812
|
-
self._get_lock_state(appliance, data)
|
813
|
-
|
814
|
-
for toggle, name in TOGGLES.items():
|
815
|
-
self._get_toggle_state(appliance, toggle, name, data)
|
816
|
-
|
817
|
-
if appliance.find("type").text in ACTUATOR_CLASSES:
|
818
|
-
self._get_actuator_functionalities(appliance, device, data)
|
819
|
-
|
820
|
-
# Collect availability-status for wireless connected devices to Adam
|
821
|
-
self._wireless_availability(appliance, data)
|
822
|
-
|
823
|
-
if dev_id == self.gateway_id and self.smile(ADAM):
|
824
|
-
self._get_regulation_mode(appliance, data)
|
825
|
-
self._get_gateway_mode(appliance, data)
|
826
|
-
|
827
|
-
# Adam & Anna: the Smile outdoor_temperature is present in DOMAIN_OBJECTS and LOCATIONS - under Home
|
828
|
-
# The outdoor_temperature present in APPLIANCES is a local sensor connected to the active device
|
829
|
-
if self._is_thermostat and dev_id == self.gateway_id:
|
830
|
-
outdoor_temperature = self._object_value(
|
831
|
-
self._home_location, "outdoor_temperature"
|
832
|
-
)
|
833
|
-
if outdoor_temperature is not None:
|
834
|
-
data.update({"sensors": {"outdoor_temperature": outdoor_temperature}})
|
835
|
-
self._count += 1
|
836
|
-
|
837
|
-
if "c_heating_state" in data:
|
838
|
-
self._process_c_heating_state(data)
|
839
|
-
# Remove c_heating_state after processing
|
840
|
-
data.pop("c_heating_state")
|
841
|
-
self._count -= 1
|
842
|
-
|
843
|
-
if self._is_thermostat and self.smile(ANNA) and dev_id == self._heater_id:
|
844
|
-
# Anna+Elga: base cooling_state on the elga-status-code
|
845
|
-
if "elga_status_code" in data:
|
846
|
-
if data["thermostat_supports_cooling"]:
|
847
|
-
# Techneco Elga has cooling-capability
|
848
|
-
self._cooling_present = True
|
849
|
-
data["model"] = "Generic heater/cooler"
|
850
|
-
self._cooling_enabled = data["elga_status_code"] in [8, 9]
|
851
|
-
data["binary_sensors"]["cooling_state"] = self._cooling_active = (
|
852
|
-
data["elga_status_code"] == 8
|
853
|
-
)
|
854
|
-
# Elga has no cooling-switch
|
855
|
-
if "cooling_ena_switch" in data["switches"]:
|
856
|
-
data["switches"].pop("cooling_ena_switch")
|
857
|
-
self._count -= 1
|
858
|
-
|
859
|
-
data.pop("elga_status_code", None)
|
795
|
+
# Don't show cooling-related when no cooling present,
|
796
|
+
# but, keep cooling_enabled for Elga
|
797
|
+
if not self._cooling_present:
|
798
|
+
if "cooling_state" in data["binary_sensors"]:
|
799
|
+
data["binary_sensors"].pop("cooling_state")
|
860
800
|
self._count -= 1
|
801
|
+
if "cooling_ena_switch" in data["switches"]:
|
802
|
+
data["switches"].pop("cooling_ena_switch") # pragma: no cover
|
803
|
+
self._count -= 1 # pragma: no cover
|
804
|
+
if "cooling_enabled" in data["binary_sensors"]:
|
805
|
+
data["binary_sensors"].pop("cooling_enabled") # pragma: no cover
|
806
|
+
self._count -= 1 # pragma: no cover
|
861
807
|
|
862
|
-
|
863
|
-
|
864
|
-
|
865
|
-
self._cooling_enabled = data["binary_sensors"]["cooling_state"]
|
866
|
-
self._cooling_active = data["sensors"]["modulation_level"] == 100
|
867
|
-
# For Loria the above does not work (pw-beta issue #301)
|
868
|
-
if "cooling_ena_switch" in data["switches"]:
|
869
|
-
self._cooling_enabled = data["switches"]["cooling_ena_switch"]
|
870
|
-
self._cooling_active = data["binary_sensors"]["cooling_state"]
|
871
|
-
|
872
|
-
self._cleanup_data(data)
|
873
|
-
|
874
|
-
return data
|
808
|
+
if "thermostat_supports_cooling" in data:
|
809
|
+
data.pop("thermostat_supports_cooling", None)
|
810
|
+
self._count -= 1
|
875
811
|
|
876
812
|
def _scan_thermostats(self) -> None:
|
877
813
|
"""Helper-function for smile.py: get_all_devices().
|
878
814
|
|
879
815
|
Update locations with thermostat ranking results and use
|
880
|
-
the result to update the device_class of
|
816
|
+
the result to update the device_class of secondary thermostats.
|
881
817
|
"""
|
882
818
|
self._thermo_locs = self._match_locations()
|
883
819
|
|
@@ -892,11 +828,11 @@ class SmileHelper(SmileCommon):
|
|
892
828
|
for dev_id, device in self.gw_devices.items():
|
893
829
|
self._rank_thermostat(thermo_matching, loc_id, dev_id, device)
|
894
830
|
|
895
|
-
# Update
|
831
|
+
# Update secondary thermostat class where needed
|
896
832
|
for dev_id, device in self.gw_devices.items():
|
897
833
|
if (loc_id := device["location"]) in self._thermo_locs:
|
898
834
|
tl_loc_id = self._thermo_locs[loc_id]
|
899
|
-
if "
|
835
|
+
if "secondary" in tl_loc_id and dev_id in tl_loc_id["secondary"]:
|
900
836
|
device["dev_class"] = "thermo_sensor"
|
901
837
|
|
902
838
|
def _match_locations(self) -> dict[str, ThermoLoc]:
|
@@ -909,7 +845,7 @@ class SmileHelper(SmileCommon):
|
|
909
845
|
for appliance_details in self.gw_devices.values():
|
910
846
|
if appliance_details["location"] == location_id:
|
911
847
|
location_details.update(
|
912
|
-
{"
|
848
|
+
{"primary": None, "primary_prio": 0, "secondary": set()}
|
913
849
|
)
|
914
850
|
matched_locations[location_id] = location_details
|
915
851
|
|
@@ -924,69 +860,38 @@ class SmileHelper(SmileCommon):
|
|
924
860
|
) -> None:
|
925
861
|
"""Helper-function for _scan_thermostats().
|
926
862
|
|
927
|
-
Rank the thermostat based on appliance_details:
|
863
|
+
Rank the thermostat based on appliance_details: primary or secondary.
|
928
864
|
"""
|
929
865
|
appl_class = appliance_details["dev_class"]
|
930
866
|
appl_d_loc = appliance_details["location"]
|
931
867
|
if loc_id == appl_d_loc and appl_class in thermo_matching:
|
932
|
-
# Pre-elect new
|
933
|
-
if thermo_matching[appl_class] > self._thermo_locs[loc_id]["
|
934
|
-
# Demote former
|
935
|
-
if (
|
936
|
-
self._thermo_locs[loc_id]["
|
868
|
+
# Pre-elect new primary
|
869
|
+
if thermo_matching[appl_class] > self._thermo_locs[loc_id]["primary_prio"]:
|
870
|
+
# Demote former primary
|
871
|
+
if (tl_primary:= self._thermo_locs[loc_id]["primary"]) is not None:
|
872
|
+
self._thermo_locs[loc_id]["secondary"].add(tl_primary)
|
937
873
|
|
938
|
-
# Crown
|
939
|
-
self._thermo_locs[loc_id]["
|
940
|
-
self._thermo_locs[loc_id]["
|
874
|
+
# Crown primary
|
875
|
+
self._thermo_locs[loc_id]["primary_prio"] = thermo_matching[appl_class]
|
876
|
+
self._thermo_locs[loc_id]["primary"] = appliance_id
|
941
877
|
|
942
878
|
else:
|
943
|
-
self._thermo_locs[loc_id]["
|
879
|
+
self._thermo_locs[loc_id]["secondary"].add(appliance_id)
|
944
880
|
|
945
|
-
def
|
946
|
-
"""Helper-function for
|
947
|
-
|
948
|
-
Determine the location-set_temperature uri - from LOCATIONS.
|
949
|
-
"""
|
950
|
-
locator = f'./location[@id="{loc_id}"]/actuator_functionalities/thermostat_functionality'
|
951
|
-
thermostat_functionality_id = self._domain_objects.find(locator).attrib["id"]
|
952
|
-
|
953
|
-
return f"{LOCATIONS};id={loc_id}/thermostat;id={thermostat_functionality_id}"
|
954
|
-
|
955
|
-
def _get_group_switches(self) -> dict[str, DeviceData]:
|
956
|
-
"""Helper-function for smile.py: get_all_devices().
|
881
|
+
def _control_state(self, loc_id: str) -> str | bool:
|
882
|
+
"""Helper-function for _device_data_adam().
|
957
883
|
|
958
|
-
|
884
|
+
Adam: find the thermostat control_state of a location, from DOMAIN_OBJECTS.
|
885
|
+
Represents the heating/cooling demand-state of the local primary thermostat.
|
886
|
+
Note: heating or cooling can still be active when the setpoint has been reached.
|
959
887
|
"""
|
960
|
-
|
961
|
-
|
962
|
-
|
963
|
-
|
964
|
-
|
965
|
-
for group in self._domain_objects.findall("./group"):
|
966
|
-
members: list[str] = []
|
967
|
-
group_id = group.attrib["id"]
|
968
|
-
group_name = group.find("name").text
|
969
|
-
group_type = group.find("type").text
|
970
|
-
group_appliances = group.findall("appliances/appliance")
|
971
|
-
# Check if members are not orphaned
|
972
|
-
for item in group_appliances:
|
973
|
-
if item.attrib["id"] in self.gw_devices:
|
974
|
-
members.append(item.attrib["id"])
|
975
|
-
|
976
|
-
if group_type in SWITCH_GROUP_TYPES and members:
|
977
|
-
switch_groups.update(
|
978
|
-
{
|
979
|
-
group_id: {
|
980
|
-
"dev_class": group_type,
|
981
|
-
"model": "Switchgroup",
|
982
|
-
"name": group_name,
|
983
|
-
"members": members,
|
984
|
-
},
|
985
|
-
},
|
986
|
-
)
|
987
|
-
self._count += 4
|
888
|
+
locator = f'location[@id="{loc_id}"]'
|
889
|
+
if (location := self._domain_objects.find(locator)) is not None:
|
890
|
+
locator = './actuator_functionalities/thermostat_functionality[type="thermostat"]/control_state'
|
891
|
+
if (ctrl_state := location.find(locator)) is not None:
|
892
|
+
return str(ctrl_state.text)
|
988
893
|
|
989
|
-
return
|
894
|
+
return False
|
990
895
|
|
991
896
|
def _heating_valves(self) -> int | bool:
|
992
897
|
"""Helper-function for smile.py: _device_data_adam().
|
@@ -1005,117 +910,73 @@ class SmileHelper(SmileCommon):
|
|
1005
910
|
|
1006
911
|
return False if loc_found == 0 else open_valve_count
|
1007
912
|
|
1008
|
-
def
|
1009
|
-
|
1010
|
-
measurement: str,
|
1011
|
-
net_string: SensorType,
|
1012
|
-
f_val: float | int,
|
1013
|
-
direct_data: DeviceData,
|
1014
|
-
) -> DeviceData:
|
1015
|
-
"""Calculate differential energy."""
|
1016
|
-
if (
|
1017
|
-
"electricity" in measurement
|
1018
|
-
and "phase" not in measurement
|
1019
|
-
and "interval" not in net_string
|
1020
|
-
):
|
1021
|
-
diff = 1
|
1022
|
-
if "produced" in measurement:
|
1023
|
-
diff = -1
|
1024
|
-
if net_string not in direct_data["sensors"]:
|
1025
|
-
tmp_val: float | int = 0
|
1026
|
-
else:
|
1027
|
-
tmp_val = direct_data["sensors"][net_string]
|
913
|
+
def _preset(self, loc_id: str) -> str | None:
|
914
|
+
"""Helper-function for smile.py: device_data_climate().
|
1028
915
|
|
1029
|
-
|
1030
|
-
|
1031
|
-
|
1032
|
-
|
1033
|
-
|
916
|
+
Collect the active preset based on Location ID.
|
917
|
+
"""
|
918
|
+
locator = f'./location[@id="{loc_id}"]/preset'
|
919
|
+
if (preset := self._domain_objects.find(locator)) is not None:
|
920
|
+
return str(preset.text)
|
1034
921
|
|
1035
|
-
|
922
|
+
return None # pragma: no cover
|
1036
923
|
|
1037
|
-
|
924
|
+
def _presets(self, loc_id: str) -> dict[str, list[float]]:
|
925
|
+
"""Collect Presets for a Thermostat based on location_id."""
|
926
|
+
presets: dict[str, list[float]] = {}
|
927
|
+
tag_1 = "zone_setpoint_and_state_based_on_preset"
|
928
|
+
tag_2 = "Thermostat presets"
|
929
|
+
if not (rule_ids := self._rule_ids_by_tag(tag_1, loc_id)):
|
930
|
+
if not (rule_ids := self._rule_ids_by_name(tag_2, loc_id)):
|
931
|
+
return presets # pragma: no cover
|
1038
932
|
|
1039
|
-
|
1040
|
-
|
1041
|
-
|
1042
|
-
|
1043
|
-
|
1044
|
-
|
1045
|
-
|
1046
|
-
|
1047
|
-
|
1048
|
-
|
1049
|
-
loc.found = False
|
1050
|
-
return loc
|
933
|
+
for rule_id in rule_ids:
|
934
|
+
directives: etree = self._domain_objects.find(
|
935
|
+
f'rule[@id="{rule_id}"]/directives'
|
936
|
+
)
|
937
|
+
for directive in directives:
|
938
|
+
preset = directive.find("then").attrib
|
939
|
+
presets[directive.attrib["preset"]] = [
|
940
|
+
float(preset["heating_setpoint"]),
|
941
|
+
float(preset["cooling_setpoint"]),
|
942
|
+
]
|
1051
943
|
|
1052
|
-
|
1053
|
-
f'./{loc.log_type}[type="{loc.measurement}"]/period/measurement'
|
1054
|
-
)
|
1055
|
-
if loc.logs.find(loc.locator) is None:
|
1056
|
-
loc.found = False
|
1057
|
-
return loc
|
1058
|
-
else:
|
1059
|
-
loc.found = False # pragma: no cover
|
1060
|
-
return loc # pragma: no cover
|
1061
|
-
|
1062
|
-
if (peak := loc.peak_select.split("_")[1]) == "offpeak":
|
1063
|
-
peak = "off_peak"
|
1064
|
-
log_found = loc.log_type.split("_")[0]
|
1065
|
-
loc.key_string = f"{loc.measurement}_{peak}_{log_found}"
|
1066
|
-
if "gas" in loc.measurement or loc.log_type == "point_meter":
|
1067
|
-
loc.key_string = f"{loc.measurement}_{log_found}"
|
1068
|
-
if "phase" in loc.measurement:
|
1069
|
-
loc.key_string = f"{loc.measurement}"
|
1070
|
-
loc.net_string = f"net_electricity_{log_found}"
|
1071
|
-
val = loc.logs.find(loc.locator).text
|
1072
|
-
loc.f_val = power_data_local_format(loc.attrs, loc.key_string, val)
|
1073
|
-
|
1074
|
-
return loc
|
944
|
+
return presets
|
1075
945
|
|
1076
|
-
def
|
1077
|
-
"""Helper-function for
|
946
|
+
def _rule_ids_by_name(self, name: str, loc_id: str) -> dict[str, dict[str, str]]:
|
947
|
+
"""Helper-function for _presets().
|
1078
948
|
|
1079
|
-
|
949
|
+
Obtain the rule_id from the given name and and provide the location_id, when present.
|
1080
950
|
"""
|
1081
|
-
|
1082
|
-
|
1083
|
-
|
1084
|
-
|
1085
|
-
|
1086
|
-
|
1087
|
-
|
1088
|
-
|
1089
|
-
for loc.measurement, loc.attrs in P1_MEASUREMENTS.items():
|
1090
|
-
for loc.log_type in log_list:
|
1091
|
-
for loc.peak_select in peak_list:
|
1092
|
-
loc.locator = (
|
1093
|
-
f'./{loc.log_type}[type="{loc.measurement}"]/period/'
|
1094
|
-
f'measurement[@{t_string}="{loc.peak_select}"]'
|
1095
|
-
)
|
1096
|
-
loc = self._power_data_peak_value(loc)
|
1097
|
-
if not loc.found:
|
1098
|
-
continue
|
1099
|
-
|
1100
|
-
direct_data = self.power_data_energy_diff(
|
1101
|
-
loc.measurement, loc.net_string, loc.f_val, direct_data
|
1102
|
-
)
|
1103
|
-
key = cast(SensorType, loc.key_string)
|
1104
|
-
direct_data["sensors"][key] = loc.f_val
|
951
|
+
schedule_ids: dict[str, dict[str, str]] = {}
|
952
|
+
locator = f'./contexts/context/zone/location[@id="{loc_id}"]'
|
953
|
+
for rule in self._domain_objects.findall(f'./rule[name="{name}"]'):
|
954
|
+
active = rule.find("active").text
|
955
|
+
if rule.find(locator) is not None:
|
956
|
+
schedule_ids[rule.attrib["id"]] = {"location": loc_id, "name": name, "active": active}
|
957
|
+
else:
|
958
|
+
schedule_ids[rule.attrib["id"]] = {"location": NONE, "name": name, "active": active}
|
1105
959
|
|
1106
|
-
|
1107
|
-
return direct_data
|
960
|
+
return schedule_ids
|
1108
961
|
|
1109
|
-
def
|
1110
|
-
"""Helper-function for
|
962
|
+
def _rule_ids_by_tag(self, tag: str, loc_id: str) -> dict[str, dict[str, str]]:
|
963
|
+
"""Helper-function for _presets(), _schedules() and _last_active_schedule().
|
1111
964
|
|
1112
|
-
|
965
|
+
Obtain the rule_id from the given template_tag and provide the location_id, when present.
|
1113
966
|
"""
|
1114
|
-
|
1115
|
-
|
1116
|
-
|
967
|
+
schedule_ids: dict[str, dict[str, str]] = {}
|
968
|
+
locator1 = f'./template[@tag="{tag}"]'
|
969
|
+
locator2 = f'./contexts/context/zone/location[@id="{loc_id}"]'
|
970
|
+
for rule in self._domain_objects.findall("./rule"):
|
971
|
+
if rule.find(locator1) is not None:
|
972
|
+
name = rule.find("name").text
|
973
|
+
active = rule.find("active").text
|
974
|
+
if rule.find(locator2) is not None:
|
975
|
+
schedule_ids[rule.attrib["id"]] = {"location": loc_id, "name": name, "active": active}
|
976
|
+
else:
|
977
|
+
schedule_ids[rule.attrib["id"]] = {"location": NONE, "name": name, "active": active}
|
1117
978
|
|
1118
|
-
return
|
979
|
+
return schedule_ids
|
1119
980
|
|
1120
981
|
def _schedules(self, location: str) -> tuple[list[str], str]:
|
1121
982
|
"""Helper-function for smile.py: _device_data_climate().
|
@@ -1174,57 +1035,12 @@ class SmileHelper(SmileCommon):
|
|
1174
1035
|
|
1175
1036
|
return sorted(schedules_dates.items(), key=lambda kv: kv[1])[-1][0]
|
1176
1037
|
|
1177
|
-
def
|
1178
|
-
"""Helper-function for smile.py:
|
1179
|
-
|
1180
|
-
Obtain the value/state for the given object from a location in DOMAIN_OBJECTS
|
1181
|
-
"""
|
1182
|
-
val: float | int | None = None
|
1183
|
-
search = self._domain_objects
|
1184
|
-
locator = f'./location[@id="{obj_id}"]/logs/point_log[type="{measurement}"]/period/measurement'
|
1185
|
-
if (found := search.find(locator)) is not None:
|
1186
|
-
val = format_measure(found.text, NONE)
|
1187
|
-
|
1188
|
-
return val
|
1189
|
-
|
1190
|
-
def _get_lock_state(self, xml: etree, data: DeviceData) -> None:
|
1191
|
-
"""Helper-function for _get_measurement_data().
|
1192
|
-
|
1193
|
-
Adam & Stretches: obtain the relay-switch lock state.
|
1194
|
-
"""
|
1195
|
-
actuator = "actuator_functionalities"
|
1196
|
-
func_type = "relay_functionality"
|
1197
|
-
if xml.find("type").text not in SPECIAL_PLUG_TYPES:
|
1198
|
-
locator = f"./{actuator}/{func_type}/lock"
|
1199
|
-
if (found := xml.find(locator)) is not None:
|
1200
|
-
data["switches"]["lock"] = found.text == "true"
|
1201
|
-
self._count += 1
|
1202
|
-
|
1203
|
-
def _get_toggle_state(
|
1204
|
-
self, xml: etree, toggle: str, name: ToggleNameType, data: DeviceData
|
1205
|
-
) -> None:
|
1206
|
-
"""Helper-function for _get_measurement_data().
|
1038
|
+
def _thermostat_uri(self, loc_id: str) -> str:
|
1039
|
+
"""Helper-function for smile.py: set_temperature().
|
1207
1040
|
|
1208
|
-
|
1041
|
+
Determine the location-set_temperature uri - from LOCATIONS.
|
1209
1042
|
"""
|
1210
|
-
|
1211
|
-
|
1212
|
-
if (state := xml.find(locator)) is not None:
|
1213
|
-
data["switches"][name] = state.text == "on"
|
1214
|
-
self._count += 1
|
1043
|
+
locator = f'./location[@id="{loc_id}"]/actuator_functionalities/thermostat_functionality'
|
1044
|
+
thermostat_functionality_id = self._domain_objects.find(locator).attrib["id"]
|
1215
1045
|
|
1216
|
-
|
1217
|
-
"""Collect the Plugwise notifications."""
|
1218
|
-
self._notifications = {}
|
1219
|
-
for notification in self._domain_objects.findall("./notification"):
|
1220
|
-
try:
|
1221
|
-
msg_id = notification.attrib["id"]
|
1222
|
-
msg_type = notification.find("type").text
|
1223
|
-
msg = notification.find("message").text
|
1224
|
-
self._notifications.update({msg_id: {msg_type: msg}})
|
1225
|
-
LOGGER.debug("Plugwise notifications: %s", self._notifications)
|
1226
|
-
except AttributeError: # pragma: no cover
|
1227
|
-
LOGGER.debug(
|
1228
|
-
"Plugwise notification present but unable to process, manually investigate: %s",
|
1229
|
-
f"{self._endpoint}{DOMAIN_OBJECTS}",
|
1230
|
-
)
|
1046
|
+
return f"{LOCATIONS};id={loc_id}/thermostat;id={thermostat_functionality_id}"
|