plugwise 0.37.1a2__py3-none-any.whl → 0.37.2__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/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
- power_data_local_format,
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.gw_devices[appl.dev_id] = {"dev_class": appl.pwclass}
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 _control_state(self, loc_id: str) -> str | bool:
477
- """Helper-function for _device_data_adam().
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
- Adam: find the thermostat control_state of a location, from DOMAIN_OBJECTS.
480
- Represents the heating/cooling demand-state of the local master thermostat.
481
- Note: heating or cooling can still be active when the setpoint has been reached.
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
- locator = f'location[@id="{loc_id}"]'
484
- if (location := self._domain_objects.find(locator)) is not None:
485
- locator = './actuator_functionalities/thermostat_functionality[type="thermostat"]/control_state'
486
- if (ctrl_state := location.find(locator)) is not None:
487
- return str(ctrl_state.text)
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
- return False
375
+ return appl
490
376
 
491
- def _presets(self, loc_id: str) -> dict[str, list[float]]:
492
- """Collect Presets for a Thermostat based on location_id."""
493
- presets: dict[str, list[float]] = {}
494
- tag_1 = "zone_setpoint_and_state_based_on_preset"
495
- tag_2 = "Thermostat presets"
496
- if not (rule_ids := self._rule_ids_by_tag(tag_1, loc_id)):
497
- if not (rule_ids := self._rule_ids_by_name(tag_2, loc_id)):
498
- return presets # pragma: no cover
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
- for rule_id in rule_ids:
501
- directives: etree = self._domain_objects.find(
502
- f'rule[@id="{rule_id}"]/directives'
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
- return presets
391
+ return appl
512
392
 
513
- def _rule_ids_by_name(self, name: str, loc_id: str) -> dict[str, dict[str, str]]:
514
- """Helper-function for _presets().
393
+ return appl # pragma: no cover
515
394
 
516
- Obtain the rule_id from the given name and and provide the location_id, when present.
517
- """
518
- schedule_ids: dict[str, dict[str, str]] = {}
519
- locator = f'./contexts/context/zone/location[@id="{loc_id}"]'
520
- for rule in self._domain_objects.findall(f'./rule[name="{name}"]'):
521
- active = rule.find("active").text
522
- if rule.find(locator) is not None:
523
- schedule_ids[rule.attrib["id"]] = {"location": loc_id, "name": name, "active": active}
524
- else:
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
- return schedule_ids
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
- def _rule_ids_by_tag(self, tag: str, loc_id: str) -> dict[str, dict[str, str]]:
530
- """Helper-function for _presets(), _schedules() and _last_active_schedule().
410
+ # Also, collect regulation_modes and check for cooling, indicating cooling-mode is present
411
+ self._appl_regulation_mode_info(appliance)
531
412
 
532
- Obtain the rule_id from the given template_tag and provide the location_id, when present.
533
- """
534
- schedule_ids: dict[str, dict[str, str]] = {}
535
- locator1 = f'./template[@tag="{tag}"]'
536
- locator2 = f'./contexts/context/zone/location[@id="{loc_id}"]'
537
- for rule in self._domain_objects.findall("./rule"):
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 schedule_ids
420
+ return appl
547
421
 
548
- def _appliance_measurements(
549
- self,
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
- # Skip known obsolete measurements
559
- updated_date_locator = (
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 _wireless_availability(self, appliance: etree, data: DeviceData) -> None:
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
- Collect the availability-status for wireless connected devices.
630
+ Obtain the toggle state of a 'toggle' = switch.
632
631
  """
633
- if self.smile(ADAM):
634
- # Collect for Plugs
635
- locator = "./logs/interval_log/electricity_interval_meter"
636
- mod_type = "electricity_interval_meter"
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 _get_appliances_with_offset_functionality(self) -> list[str]:
649
- """Helper-function collecting all appliance that have offset_functionality."""
650
- therm_list: list[str] = []
651
- offset_appls = self._domain_objects.findall(
652
- './/actuator_functionalities/offset_functionality[type="temperature_offset"]/offset/../../..'
653
- )
654
- for item in offset_appls:
655
- therm_list.append(item.attrib["id"])
656
-
657
- return therm_list
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 _cleanup_data(self, data: DeviceData) -> None:
735
- """Helper-function for _get_measurement_data().
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
- Clean up the data dict.
752
+ Obtain the value/state for the given object from a location in DOMAIN_OBJECTS
738
753
  """
739
- # Don't show cooling-related when no cooling present,
740
- # but, keep cooling_enabled for Elga
741
- if not self._cooling_present:
742
- if "cooling_state" in data["binary_sensors"]:
743
- data["binary_sensors"].pop("cooling_state")
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
- if "thermostat_supports_cooling" in data:
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 _get_measurement_data(self, dev_id: str) -> DeviceData:
785
- """Helper-function for smile.py: _get_device_data().
790
+ def _cleanup_data(self, data: DeviceData) -> None:
791
+ """Helper-function for _get_measurement_data().
786
792
 
787
- Collect the appliance-data based on device id.
793
+ Clean up the data dict.
788
794
  """
789
- data: DeviceData = {"binary_sensors": {}, "sensors": {}, "switches": {}}
790
- # Get P1 smartmeter data from LOCATIONS
791
- device = self.gw_devices[dev_id]
792
- # !! DON'T CHANGE below two if-lines, will break stuff !!
793
- if self.smile_type == "power":
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
- # Loria/Thermastage: cooling-related is based on cooling_state
863
- # and modulation_level
864
- elif self._cooling_present and "cooling_state" in data["binary_sensors"]:
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 slave thermostats.
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 slave thermostat class where needed
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 "slaves" in tl_loc_id and dev_id in tl_loc_id["slaves"]:
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
- {"master": None, "master_prio": 0, "slaves": set()}
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: master or slave.
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 master
933
- if thermo_matching[appl_class] > self._thermo_locs[loc_id]["master_prio"]:
934
- # Demote former master
935
- if (tl_master := self._thermo_locs[loc_id]["master"]) is not None:
936
- self._thermo_locs[loc_id]["slaves"].add(tl_master)
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 master
939
- self._thermo_locs[loc_id]["master_prio"] = thermo_matching[appl_class]
940
- self._thermo_locs[loc_id]["master"] = appliance_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]["slaves"].add(appliance_id)
879
+ self._thermo_locs[loc_id]["secondary"].add(appliance_id)
944
880
 
945
- def _thermostat_uri(self, loc_id: str) -> str:
946
- """Helper-function for smile.py: set_temperature().
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
- Collect switching- or pump-group info.
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
- switch_groups: dict[str, DeviceData] = {}
961
- # P1 and Anna don't have switchgroups
962
- if self.smile_type == "power" or self.smile(ANNA):
963
- return switch_groups
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 switch_groups
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 power_data_energy_diff(
1009
- self,
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
- if isinstance(f_val, int):
1030
- tmp_val += f_val * diff
1031
- else:
1032
- tmp_val += float(f_val * diff)
1033
- tmp_val = float(f"{round(tmp_val, 3):.3f}")
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
- direct_data["sensors"][net_string] = tmp_val
922
+ return None # pragma: no cover
1036
923
 
1037
- return direct_data
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
- def _power_data_peak_value(self, loc: Munch) -> Munch:
1040
- """Helper-function for _power_data_from_location() and _power_data_from_modules()."""
1041
- loc.found = True
1042
- # If locator not found look for P1 gas_consumed or phase data (without tariff)
1043
- if loc.logs.find(loc.locator) is None:
1044
- if "log" in loc.log_type and (
1045
- "gas" in loc.measurement or "phase" in loc.measurement
1046
- ):
1047
- # Avoid double processing by skipping one peak-list option
1048
- if loc.peak_select == "nl_offpeak":
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
- loc.locator = (
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 _power_data_from_location(self, loc_id: str) -> DeviceData:
1077
- """Helper-function for smile.py: _get_device_data().
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
- Collect the power-data based on Location ID, from LOCATIONS.
949
+ Obtain the rule_id from the given name and and provide the location_id, when present.
1080
950
  """
1081
- direct_data: DeviceData = {"sensors": {}}
1082
- loc = Munch()
1083
- log_list: list[str] = ["point_log", "cumulative_log", "interval_log"]
1084
- peak_list: list[str] = ["nl_peak", "nl_offpeak"]
1085
- t_string = "tariff"
1086
-
1087
- search = self._domain_objects
1088
- loc.logs = search.find(f'./location[@id="{loc_id}"]/logs')
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
- self._count += len(direct_data["sensors"])
1107
- return direct_data
960
+ return schedule_ids
1108
961
 
1109
- def _preset(self, loc_id: str) -> str | None:
1110
- """Helper-function for smile.py: device_data_climate().
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
- Collect the active preset based on Location ID.
965
+ Obtain the rule_id from the given template_tag and provide the location_id, when present.
1113
966
  """
1114
- locator = f'./location[@id="{loc_id}"]/preset'
1115
- if (preset := self._domain_objects.find(locator)) is not None:
1116
- return str(preset.text)
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 None # pragma: no cover
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 _object_value(self, obj_id: str, measurement: str) -> float | int | None:
1178
- """Helper-function for smile.py: _get_device_data() and _device_data_anna().
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
- Obtain the toggle state of a 'toggle' = switch.
1041
+ Determine the location-set_temperature uri - from LOCATIONS.
1209
1042
  """
1210
- if xml.find("type").text == "heater_central":
1211
- locator = f"./actuator_functionalities/toggle_functionality[type='{toggle}']/state"
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
- def _get_plugwise_notifications(self) -> None:
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}"