plugwise 1.6.3__py3-none-any.whl → 1.7.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
plugwise/helper.py CHANGED
@@ -2,9 +2,9 @@
2
2
 
3
3
  Plugwise Smile protocol helpers.
4
4
  """
5
+
5
6
  from __future__ import annotations
6
7
 
7
- import asyncio
8
8
  import datetime as dt
9
9
  from typing import cast
10
10
 
@@ -28,6 +28,7 @@ from plugwise.constants import (
28
28
  NONE,
29
29
  OFF,
30
30
  P1_MEASUREMENTS,
31
+ PRIORITY_DEVICE_CLASSES,
31
32
  TEMP_CELSIUS,
32
33
  THERMOSTAT_CLASSES,
33
34
  TOGGLES,
@@ -36,29 +37,20 @@ from plugwise.constants import (
36
37
  ActuatorData,
37
38
  ActuatorDataType,
38
39
  ActuatorType,
39
- GatewayData,
40
40
  GwEntityData,
41
41
  SensorType,
42
42
  ThermoLoc,
43
43
  ToggleNameType,
44
44
  )
45
- from plugwise.exceptions import (
46
- ConnectionFailedError,
47
- InvalidAuthentication,
48
- InvalidXMLError,
49
- ResponseError,
50
- )
51
45
  from plugwise.util import (
52
46
  check_model,
47
+ collect_power_values,
53
48
  common_match_cases,
54
- escape_illegal_xml_characters,
49
+ count_data_items,
55
50
  format_measure,
56
51
  skip_obsolete_measurements,
57
52
  )
58
53
 
59
- # This way of importing aiohttp is because of patch/mocking in testing (aiohttp timeouts)
60
- from aiohttp import BasicAuth, ClientError, ClientResponse, ClientSession, ClientTimeout
61
-
62
54
  # Time related
63
55
  from dateutil import tz
64
56
  from dateutil.parser import parse
@@ -67,144 +59,13 @@ from munch import Munch
67
59
  from packaging import version
68
60
 
69
61
 
70
- class SmileComm:
71
- """The SmileComm class."""
72
-
73
- def __init__(
74
- self,
75
- host: str,
76
- password: str,
77
- port: int,
78
- timeout: int,
79
- username: str,
80
- websession: ClientSession | None,
81
- ) -> None:
82
- """Set the constructor for this class."""
83
- if not websession:
84
- aio_timeout = ClientTimeout(total=timeout)
85
-
86
- async def _create_session() -> ClientSession:
87
- return ClientSession(timeout=aio_timeout) # pragma: no cover
88
-
89
- loop = asyncio.get_event_loop()
90
- if loop.is_running():
91
- self._websession = ClientSession(timeout=aio_timeout)
92
- else:
93
- self._websession = loop.run_until_complete(
94
- _create_session()
95
- ) # pragma: no cover
96
- else:
97
- self._websession = websession
62
+ def search_actuator_functionalities(appliance: etree, actuator: str) -> etree | None:
63
+ """Helper-function for finding the relevant actuator xml-structure."""
64
+ locator = f"./actuator_functionalities/{actuator}"
65
+ if (search := appliance.find(locator)) is not None:
66
+ return search
98
67
 
99
- # Quickfix IPv6 formatting, not covering
100
- if host.count(":") > 2: # pragma: no cover
101
- host = f"[{host}]"
102
-
103
- self._auth = BasicAuth(username, password=password)
104
- self._endpoint = f"http://{host}:{str(port)}"
105
-
106
- async def _request(
107
- self,
108
- command: str,
109
- retry: int = 3,
110
- method: str = "get",
111
- data: str | None = None,
112
- headers: dict[str, str] | None = None,
113
- ) -> etree:
114
- """Get/put/delete data from a give URL."""
115
- resp: ClientResponse
116
- url = f"{self._endpoint}{command}"
117
- use_headers = headers
118
-
119
- try:
120
- match method:
121
- case "delete":
122
- resp = await self._websession.delete(url, auth=self._auth)
123
- case "get":
124
- # Work-around for Stretchv2, should not hurt the other smiles
125
- use_headers = {"Accept-Encoding": "gzip"}
126
- resp = await self._websession.get(
127
- url, headers=use_headers, auth=self._auth
128
- )
129
- case "post":
130
- use_headers = {"Content-type": "text/xml"}
131
- resp = await self._websession.post(
132
- url,
133
- headers=use_headers,
134
- data=data,
135
- auth=self._auth,
136
- )
137
- case "put":
138
- use_headers = {"Content-type": "text/xml"}
139
- resp = await self._websession.put(
140
- url,
141
- headers=use_headers,
142
- data=data,
143
- auth=self._auth,
144
- )
145
- except (
146
- ClientError
147
- ) as exc: # ClientError is an ancestor class of ServerTimeoutError
148
- if retry < 1:
149
- LOGGER.warning(
150
- "Failed sending %s %s to Plugwise Smile, error: %s",
151
- method,
152
- command,
153
- exc,
154
- )
155
- raise ConnectionFailedError from exc
156
- return await self._request(command, retry - 1)
157
-
158
- if resp.status == 504:
159
- if retry < 1:
160
- LOGGER.warning(
161
- "Failed sending %s %s to Plugwise Smile, error: %s",
162
- method,
163
- command,
164
- "504 Gateway Timeout",
165
- )
166
- raise ConnectionFailedError
167
- return await self._request(command, retry - 1)
168
-
169
- return await self._request_validate(resp, method)
170
-
171
- async def _request_validate(self, resp: ClientResponse, method: str) -> etree:
172
- """Helper-function for _request(): validate the returned data."""
173
- match resp.status:
174
- case 200:
175
- # Cornercases for server not responding with 202
176
- if method in ("post", "put"):
177
- return
178
- case 202:
179
- # Command accepted gives empty body with status 202
180
- return
181
- case 401:
182
- msg = "Invalid Plugwise login, please retry with the correct credentials."
183
- LOGGER.error("%s", msg)
184
- raise InvalidAuthentication
185
- case 405:
186
- msg = "405 Method not allowed."
187
- LOGGER.error("%s", msg)
188
- raise ConnectionFailedError
189
-
190
- if not (result := await resp.text()) or (
191
- "<error>" in result and "Not started" not in result
192
- ):
193
- LOGGER.warning("Smile response empty or error in %s", result)
194
- raise ResponseError
195
-
196
- try:
197
- # Encode to ensure utf8 parsing
198
- xml = etree.XML(escape_illegal_xml_characters(result).encode())
199
- except etree.ParseError as exc:
200
- LOGGER.warning("Smile returns invalid XML for %s", self._endpoint)
201
- raise InvalidXMLError from exc
202
-
203
- return xml
204
-
205
- async def close_connection(self) -> None:
206
- """Close the Plugwise connection."""
207
- await self._websession.close()
68
+ return None
208
69
 
209
70
 
210
71
  class SmileHelper(SmileCommon):
@@ -212,61 +73,28 @@ class SmileHelper(SmileCommon):
212
73
 
213
74
  def __init__(self) -> None:
214
75
  """Set the constructor for this class."""
215
- self._cooling_present: bool
216
- self._count: int
217
- self._dhw_allowed_modes: list[str] = []
218
- self._domain_objects: etree
219
76
  self._endpoint: str
220
77
  self._elga: bool
221
- self._gw_allowed_modes: list[str] = []
222
- self._heater_id: str
223
- self._home_location: str
224
78
  self._is_thermostat: bool
225
79
  self._last_active: dict[str, str | None]
226
- self._last_modified: dict[str, str] = {}
227
80
  self._loc_data: dict[str, ThermoLoc]
228
- self._notifications: dict[str, dict[str, str]] = {}
229
- self._on_off_device: bool
230
- self._opentherm_device: bool
231
- self._reg_allowed_modes: list[str] = []
232
81
  self._schedule_old_states: dict[str, dict[str, str]]
233
- self._status: etree
234
- self._stretch_v2: bool
235
- self._system: etree
236
- self._thermo_locs: dict[str, ThermoLoc] = {}
237
- ###################################################################
238
- # '_cooling_enabled' can refer to the state of the Elga heatpump
239
- # connected to an Anna. For Elga, 'elga_status_code' in (8, 9)
240
- # means cooling mode is available, next to heating mode.
241
- # 'elga_status_code' = 8 means cooling is active, 9 means idle.
242
- #
243
- # '_cooling_enabled' cam refer to the state of the Loria or
244
- # Thermastage heatpump connected to an Anna. For these,
245
- # 'cooling_enabled' = on means set to cooling mode, instead of to
246
- # heating mode.
247
- # 'cooling_state' = on means cooling is active.
248
- ###################################################################
249
- self._cooling_active = False
250
- self._cooling_enabled = False
251
-
252
- self.gateway_id: str
253
- self.gw_data: GatewayData = {}
254
- self.gw_entities: dict[str, GwEntityData] = {}
255
- self.smile_fw_version: version.Version | None
82
+ self._gateway_id: str = NONE
83
+ self._zones: dict[str, GwEntityData]
84
+ self.gw_entities: dict[str, GwEntityData]
256
85
  self.smile_hw_version: str | None
257
86
  self.smile_mac_address: str | None
258
87
  self.smile_model: str
259
88
  self.smile_model_id: str | None
260
- self.smile_name: str
261
- self.smile_type: str
262
89
  self.smile_version: version.Version | None
263
- self.smile_zigbee_mac_address: str | None
264
- self.therms_with_offset_func: list[str] = []
265
- self._zones: dict[str, GwEntityData] = {}
266
90
  SmileCommon.__init__(self)
267
91
 
268
92
  def _all_appliances(self) -> None:
269
- """Collect all appliances with relevant info."""
93
+ """Collect all appliances with relevant info.
94
+
95
+ Also, collect the P1 smartmeter info from a location
96
+ as this one is not available as an appliance.
97
+ """
270
98
  self._count = 0
271
99
  self._all_locations()
272
100
 
@@ -294,10 +122,10 @@ class SmileHelper(SmileCommon):
294
122
  appl.location = None
295
123
  if (appl_loc := appliance.find("location")) is not None:
296
124
  appl.location = appl_loc.attrib["id"]
297
- # Don't assign the _home_location to thermostat-devices without a location,
125
+ # Don't assign the _home_loc_id to thermostat-devices without a location,
298
126
  # they are not active
299
127
  elif appl.pwclass not in THERMOSTAT_CLASSES:
300
- appl.location = self._home_location
128
+ appl.location = self._home_loc_id
301
129
 
302
130
  # Don't show orphaned thermostat-types
303
131
  if appl.pwclass in THERMOSTAT_CLASSES and appl.location is None:
@@ -318,59 +146,31 @@ class SmileHelper(SmileCommon):
318
146
  if not (appl := self._appliance_info_finder(appl, appliance)):
319
147
  continue
320
148
 
321
- # P1: for gateway and smartmeter switch entity_id - part 1
322
- # This is done to avoid breakage in HA Core
323
- if appl.pwclass == "gateway" and self.smile_type == "power":
324
- appl.entity_id = appl.location
325
-
326
149
  self._create_gw_entities(appl)
327
150
 
328
- # For P1 collect the connected SmartMeter info
329
151
  if self.smile_type == "power":
330
- self._p1_smartmeter_info_finder(appl)
331
- # P1: for gateway and smartmeter switch entity_id - part 2
332
- for item in self.gw_entities:
333
- if item != self.gateway_id:
334
- self.gateway_id = item
335
- # Leave for-loop to avoid a 2nd device_id switch
336
- break
337
-
338
- # Place the gateway and optional heater_central devices as 1st and 2nd
339
- for dev_class in ("heater_central", "gateway"):
340
- for entity_id, entity in dict(self.gw_entities).items():
341
- if entity["dev_class"] == dev_class:
342
- tmp_entity = entity
343
- self.gw_entities.pop(entity_id)
344
- cleared_dict = self.gw_entities
345
- add_to_front = {entity_id: tmp_entity}
346
- self.gw_entities = {**add_to_front, **cleared_dict}
152
+ self._get_p1_smartmeter_info()
347
153
 
348
- def _all_locations(self) -> None:
349
- """Collect all locations."""
350
- loc = Munch()
351
- locations = self._domain_objects.findall("./location")
352
- for location in locations:
353
- loc.name = location.find("name").text
354
- loc.loc_id = location.attrib["id"]
355
- if loc.name == "Home":
356
- self._home_location = loc.loc_id
154
+ # Sort the gw_entities
155
+ self._sort_gw_entities()
357
156
 
358
- self._loc_data[loc.loc_id] = {"name": loc.name}
157
+ def _get_p1_smartmeter_info(self) -> None:
158
+ """For P1 collect the connected SmartMeter info from the Home/building location.
359
159
 
360
- def _p1_smartmeter_info_finder(self, appl: Munch) -> None:
361
- """Collect P1 DSMR SmartMeter info."""
362
- loc_id = next(iter(self._loc_data.keys()))
363
- location = self._domain_objects.find(f'./location[@id="{loc_id}"]')
160
+ Note: For P1, the entity_id for the gateway and smartmeter are
161
+ switched to maintain backward compatibility with existing implementations.
162
+ """
163
+ appl = Munch()
364
164
  locator = MODULE_LOCATOR
365
- module_data = self._get_module_data(location, locator)
165
+ module_data = self._get_module_data(self._home_location, locator)
366
166
  if not module_data["contents"]:
367
167
  LOGGER.error("No module data found for SmartMeter") # pragma: no cover
368
- return None # pragma: no cover
369
-
370
- appl.entity_id = self.gateway_id
168
+ return # pragma: no cover
169
+ appl.available = None
170
+ appl.entity_id = self._gateway_id
371
171
  appl.firmware = module_data["firmware_version"]
372
172
  appl.hardware = module_data["hardware_version"]
373
- appl.location = loc_id
173
+ appl.location = self._home_loc_id
374
174
  appl.mac = None
375
175
  appl.model = module_data["vendor_model"]
376
176
  appl.model_id = None # don't use model_id for SmartMeter
@@ -379,21 +179,56 @@ class SmileHelper(SmileCommon):
379
179
  appl.vendor_name = module_data["vendor_name"]
380
180
  appl.zigbee_mac = None
381
181
 
182
+ # Replace the entity_id of the gateway by the smartmeter location_id
183
+ self.gw_entities[self._home_loc_id] = self.gw_entities.pop(self._gateway_id)
184
+ self._gateway_id = self._home_loc_id
185
+
382
186
  self._create_gw_entities(appl)
383
187
 
188
+ def _sort_gw_entities(self) -> None:
189
+ """Place the gateway and optional heater_central entities as 1st and 2nd."""
190
+ for dev_class in PRIORITY_DEVICE_CLASSES:
191
+ for entity_id, entity in dict(self.gw_entities).items():
192
+ if entity["dev_class"] == dev_class:
193
+ priority_entity = entity
194
+ self.gw_entities.pop(entity_id)
195
+ other_entities = self.gw_entities
196
+ priority_entities = {entity_id: priority_entity}
197
+ self.gw_entities = {**priority_entities, **other_entities}
198
+
199
+ def _all_locations(self) -> None:
200
+ """Collect all locations."""
201
+ loc = Munch()
202
+ locations = self._domain_objects.findall("./location")
203
+ for location in locations:
204
+ loc.name = location.find("name").text
205
+ loc.loc_id = location.attrib["id"]
206
+ self._loc_data[loc.loc_id] = {"name": loc.name}
207
+ if loc.name != "Home":
208
+ continue
209
+
210
+ self._home_loc_id = loc.loc_id
211
+ self._home_location = self._domain_objects.find(
212
+ f"./location[@id='{loc.loc_id}']"
213
+ )
214
+
384
215
  def _appliance_info_finder(self, appl: Munch, appliance: etree) -> Munch:
385
216
  """Collect info for all appliances found."""
386
217
  match appl.pwclass:
387
218
  case "gateway":
388
- # Collect gateway device info
219
+ # Collect gateway entity info
389
220
  return self._appl_gateway_info(appl, appliance)
390
221
  case _ as dev_class if dev_class in THERMOSTAT_CLASSES:
391
- # Collect thermostat device info
222
+ # Collect thermostat entity info
392
223
  return self._appl_thermostat_info(appl, appliance)
393
224
  case "heater_central":
394
- # Collect heater_central device info
395
- self._appl_heater_central_info(appl, appliance, False) # False means non-legacy device
396
- self._appl_dhw_mode_info(appl, appliance)
225
+ # Collect heater_central entity info
226
+ self._appl_heater_central_info(
227
+ appl, appliance, False
228
+ ) # False means non-legacy entity
229
+ self._dhw_allowed_modes = self._get_appl_actuator_modes(
230
+ appliance, "domestic_hot_water_mode_control_functionality"
231
+ )
397
232
  # Skip orphaned heater_central (Core Issue #104433)
398
233
  if appl.entity_id != self._heater_id:
399
234
  return Munch()
@@ -419,8 +254,8 @@ class SmileHelper(SmileCommon):
419
254
 
420
255
  def _appl_gateway_info(self, appl: Munch, appliance: etree) -> Munch:
421
256
  """Helper-function for _appliance_info_finder()."""
422
- self.gateway_id = appliance.attrib["id"]
423
- appl.firmware = str(self.smile_fw_version)
257
+ self._gateway_id = appliance.attrib["id"]
258
+ appl.firmware = str(self.smile_version)
424
259
  appl.hardware = self.smile_hw_version
425
260
  appl.mac = self.smile_mac_address
426
261
  appl.model = self.smile_model
@@ -430,11 +265,15 @@ class SmileHelper(SmileCommon):
430
265
 
431
266
  # Adam: collect the ZigBee MAC address of the Smile
432
267
  if self.smile(ADAM):
433
- if (found := self._domain_objects.find(".//protocols/zig_bee_coordinator")) is not None:
268
+ if (
269
+ found := self._domain_objects.find(".//protocols/zig_bee_coordinator")
270
+ ) is not None:
434
271
  appl.zigbee_mac = found.find("mac_address").text
435
272
 
436
273
  # Also, collect regulation_modes and check for cooling, indicating cooling-mode is present
437
- self._appl_regulation_mode_info(appliance)
274
+ self._reg_allowed_modes = self._get_appl_actuator_modes(
275
+ appliance, "regulation_mode_control_functionality"
276
+ )
438
277
 
439
278
  # Finally, collect the gateway_modes
440
279
  self._gw_allowed_modes = []
@@ -445,32 +284,18 @@ class SmileHelper(SmileCommon):
445
284
 
446
285
  return appl
447
286
 
448
- def _appl_regulation_mode_info(self, appliance: etree) -> None:
449
- """Helper-function for _appliance_info_finder()."""
450
- reg_mode_list: list[str] = []
451
- locator = "./actuator_functionalities/regulation_mode_control_functionality"
452
- if (search := appliance.find(locator)) is not None:
453
- if search.find("allowed_modes") is not None:
454
- for mode in search.find("allowed_modes"):
455
- reg_mode_list.append(mode.text)
456
- if mode.text == "cooling":
457
- self._cooling_present = True
458
- self._reg_allowed_modes = reg_mode_list
459
-
460
- def _appl_dhw_mode_info(self, appl: Munch, appliance: etree) -> Munch:
461
- """Helper-function for _appliance_info_finder().
462
-
463
- Collect dhw control operation modes - Anna + Loria.
464
- """
465
- dhw_mode_list: list[str] = []
466
- locator = "./actuator_functionalities/domestic_hot_water_mode_control_functionality"
467
- if (search := appliance.find(locator)) is not None:
468
- if search.find("allowed_modes") is not None:
469
- for mode in search.find("allowed_modes"):
470
- dhw_mode_list.append(mode.text)
471
- self._dhw_allowed_modes = dhw_mode_list
287
+ def _get_appl_actuator_modes(
288
+ self, appliance: etree, actuator_type: str
289
+ ) -> list[str]:
290
+ """Get allowed modes for the given actuator type."""
291
+ mode_list: list[str] = []
292
+ if (
293
+ search := search_actuator_functionalities(appliance, actuator_type)
294
+ ) is not None and (modes := search.find("allowed_modes")) is not None:
295
+ for mode in modes:
296
+ mode_list.append(mode.text)
472
297
 
473
- return appl
298
+ return mode_list
474
299
 
475
300
  def _get_appliances_with_offset_functionality(self) -> list[str]:
476
301
  """Helper-function collecting all appliance that have offset_functionality."""
@@ -510,7 +335,7 @@ class SmileHelper(SmileCommon):
510
335
  # !! DON'T CHANGE below two if-lines, will break stuff !!
511
336
  if self.smile_type == "power":
512
337
  if entity["dev_class"] == "smartmeter":
513
- data.update(self._power_data_from_location(entity["location"]))
338
+ data.update(self._power_data_from_location())
514
339
 
515
340
  return data
516
341
 
@@ -552,21 +377,20 @@ class SmileHelper(SmileCommon):
552
377
 
553
378
  return data
554
379
 
555
- def _power_data_from_location(self, loc_id: str) -> GwEntityData:
380
+ def _power_data_from_location(self) -> GwEntityData:
556
381
  """Helper-function for smile.py: _get_entity_data().
557
382
 
558
- Collect the power-data based on Location ID, from LOCATIONS.
383
+ Collect the power-data from the Home location.
559
384
  """
560
385
  data: GwEntityData = {"sensors": {}}
561
386
  loc = Munch()
562
387
  log_list: list[str] = ["point_log", "cumulative_log", "interval_log"]
563
388
  t_string = "tariff"
564
389
 
565
- search = self._domain_objects
566
- loc.logs = search.find(f'./location[@id="{loc_id}"]/logs')
390
+ loc.logs = self._home_location.find("./logs")
567
391
  for loc.measurement, loc.attrs in P1_MEASUREMENTS.items():
568
392
  for loc.log_type in log_list:
569
- self._collect_power_values(data, loc, t_string)
393
+ collect_power_values(data, loc, t_string)
570
394
 
571
395
  self._count += len(data["sensors"])
572
396
  return data
@@ -602,7 +426,7 @@ class SmileHelper(SmileCommon):
602
426
  appl_i_loc.text, ENERGY_WATT_HOUR
603
427
  )
604
428
 
605
- self._count_data_items(data)
429
+ self._count = count_data_items(self._count, data)
606
430
 
607
431
  def _get_toggle_state(
608
432
  self, xml: etree, toggle: str, name: ToggleNameType, data: GwEntityData
@@ -625,7 +449,7 @@ class SmileHelper(SmileCommon):
625
449
  msg_id = notification.attrib["id"]
626
450
  msg_type = notification.find("type").text
627
451
  msg = notification.find("message").text
628
- self._notifications.update({msg_id: {msg_type: msg}})
452
+ self._notifications[msg_id] = {msg_type: msg}
629
453
  LOGGER.debug("Plugwise notifications: %s", self._notifications)
630
454
  except AttributeError: # pragma: no cover
631
455
  LOGGER.debug(
@@ -636,13 +460,19 @@ class SmileHelper(SmileCommon):
636
460
  def _get_actuator_functionalities(
637
461
  self, xml: etree, entity: GwEntityData, data: GwEntityData
638
462
  ) -> None:
639
- """Helper-function for _get_measurement_data()."""
463
+ """Get and process the actuator_functionalities details for an entity.
464
+
465
+ Add the resulting dict(s) to the entity's data.
466
+ """
640
467
  for item in ACTIVE_ACTUATORS:
641
468
  # Skip max_dhw_temperature, not initially valid,
642
469
  # skip thermostat for all but zones with thermostats
643
470
  if item == "max_dhw_temperature" or (
644
- item == "thermostat" and (
645
- entity["dev_class"] != "climate" if self.smile(ADAM) else entity["dev_class"] != "thermostat"
471
+ item == "thermostat"
472
+ and (
473
+ entity["dev_class"] != "climate"
474
+ if self.smile(ADAM)
475
+ else entity["dev_class"] != "thermostat"
646
476
  )
647
477
  ):
648
478
  continue
@@ -689,6 +519,21 @@ class SmileHelper(SmileCommon):
689
519
  act_item = cast(ActuatorType, item)
690
520
  data[act_item] = temp_dict
691
521
 
522
+ def _get_actuator_mode(
523
+ self, appliance: etree, entity_id: str, key: str
524
+ ) -> str | None:
525
+ """Helper-function for _get_regulation_mode and _get_gateway_mode.
526
+
527
+ Collect the requested gateway mode.
528
+ """
529
+ if not (self.smile(ADAM) and entity_id == self._gateway_id):
530
+ return None
531
+
532
+ if (search := search_actuator_functionalities(appliance, key)) is not None:
533
+ return str(search.find("mode").text)
534
+
535
+ return None
536
+
692
537
  def _get_regulation_mode(
693
538
  self, appliance: etree, entity_id: str, data: GwEntityData
694
539
  ) -> None:
@@ -696,14 +541,14 @@ class SmileHelper(SmileCommon):
696
541
 
697
542
  Adam: collect the gateway regulation_mode.
698
543
  """
699
- if not (self.smile(ADAM) and entity_id == self.gateway_id):
700
- return
701
-
702
- locator = "./actuator_functionalities/regulation_mode_control_functionality"
703
- if (search := appliance.find(locator)) is not None:
704
- data["select_regulation_mode"] = search.find("mode").text
544
+ if (
545
+ mode := self._get_actuator_mode(
546
+ appliance, entity_id, "regulation_mode_control_functionality"
547
+ )
548
+ ) is not None:
549
+ data["select_regulation_mode"] = mode
705
550
  self._count += 1
706
- self._cooling_enabled = data["select_regulation_mode"] == "cooling"
551
+ self._cooling_enabled = mode == "cooling"
707
552
 
708
553
  def _get_gateway_mode(
709
554
  self, appliance: etree, entity_id: str, data: GwEntityData
@@ -712,40 +557,23 @@ class SmileHelper(SmileCommon):
712
557
 
713
558
  Adam: collect the gateway mode.
714
559
  """
715
- if not (self.smile(ADAM) and entity_id == self.gateway_id):
716
- return
717
-
718
- locator = "./actuator_functionalities/gateway_mode_control_functionality"
719
- if (search := appliance.find(locator)) is not None:
720
- data["select_gateway_mode"] = search.find("mode").text
560
+ if (
561
+ mode := self._get_actuator_mode(
562
+ appliance, entity_id, "gateway_mode_control_functionality"
563
+ )
564
+ ) is not None:
565
+ data["select_gateway_mode"] = mode
721
566
  self._count += 1
722
567
 
723
568
  def _get_gateway_outdoor_temp(self, entity_id: str, data: GwEntityData) -> None:
724
- """Adam & Anna: the Smile outdoor_temperature is present in DOMAIN_OBJECTS and LOCATIONS.
725
-
726
- Available under the Home location.
727
- """
728
- if self._is_thermostat and entity_id == self.gateway_id:
729
- outdoor_temperature = self._object_value(
730
- self._home_location, "outdoor_temperature"
731
- )
732
- if outdoor_temperature is not None:
733
- data.update({"sensors": {"outdoor_temperature": outdoor_temperature}})
569
+ """Adam & Anna: the Smile outdoor_temperature is present in the Home location."""
570
+ if self._is_thermostat and entity_id == self._gateway_id:
571
+ locator = "./logs/point_log[type='outdoor_temperature']/period/measurement"
572
+ if (found := self._home_location.find(locator)) is not None:
573
+ value = format_measure(found.text, NONE)
574
+ data.update({"sensors": {"outdoor_temperature": value}})
734
575
  self._count += 1
735
576
 
736
- def _object_value(self, obj_id: str, measurement: str) -> float | int | None:
737
- """Helper-function for smile.py: _get_entity_data().
738
-
739
- Obtain the value/state for the given object from a location in DOMAIN_OBJECTS
740
- """
741
- val: float | int | None = None
742
- search = self._domain_objects
743
- locator = f'./location[@id="{obj_id}"]/logs/point_log[type="{measurement}"]/period/measurement'
744
- if (found := search.find(locator)) is not None:
745
- val = format_measure(found.text, NONE)
746
-
747
- return val
748
-
749
577
  def _process_c_heating_state(self, data: GwEntityData) -> None:
750
578
  """Helper-function for _get_measurement_data().
751
579
 
@@ -768,19 +596,23 @@ class SmileHelper(SmileCommon):
768
596
  data["binary_sensors"]["heating_state"] = data["c_heating_state"]
769
597
 
770
598
  if self.smile(ADAM):
599
+ # First count when not present, then create and init to False.
600
+ # When present init to False
771
601
  if "heating_state" not in data["binary_sensors"]:
772
602
  self._count += 1
773
603
  data["binary_sensors"]["heating_state"] = False
604
+
774
605
  if "cooling_state" not in data["binary_sensors"]:
775
606
  self._count += 1
776
607
  data["binary_sensors"]["cooling_state"] = False
608
+
777
609
  if self._cooling_enabled:
778
610
  data["binary_sensors"]["cooling_state"] = data["c_heating_state"]
779
611
  else:
780
612
  data["binary_sensors"]["heating_state"] = data["c_heating_state"]
781
613
 
782
614
  def _update_anna_cooling(self, entity_id: str, data: GwEntityData) -> None:
783
- """Update the Anna heater_central device for cooling.
615
+ """Update the Anna heater_central entity for cooling.
784
616
 
785
617
  Support added for Techneco Elga and Thercon Loria/Thermastage.
786
618
  """
@@ -793,14 +625,20 @@ class SmileHelper(SmileCommon):
793
625
  self._update_loria_cooling(data)
794
626
 
795
627
  def _update_elga_cooling(self, data: GwEntityData) -> None:
796
- """# Anna+Elga: base cooling_state on the elga-status-code."""
628
+ """# Anna+Elga: base cooling_state on the elga-status-code.
629
+
630
+ For Elga, 'elga_status_code' in (8, 9) means cooling is enabled.
631
+ 'elga_status_code' = 8 means cooling is active, 9 means idle.
632
+ """
797
633
  if data["thermostat_supports_cooling"]:
798
634
  # Techneco Elga has cooling-capability
799
635
  self._cooling_present = True
800
636
  data["model"] = "Generic heater/cooler"
801
637
  # Cooling_enabled in xml does NOT show the correct status!
802
638
  # Setting it specifically:
803
- self._cooling_enabled = data["binary_sensors"]["cooling_enabled"] = data["elga_status_code"] in (8, 9)
639
+ self._cooling_enabled = data["binary_sensors"]["cooling_enabled"] = data[
640
+ "elga_status_code"
641
+ ] in (8, 9)
804
642
  data["binary_sensors"]["cooling_state"] = self._cooling_active = (
805
643
  data["elga_status_code"] == 8
806
644
  )
@@ -813,14 +651,22 @@ class SmileHelper(SmileCommon):
813
651
  self._count -= 1
814
652
 
815
653
  def _update_loria_cooling(self, data: GwEntityData) -> None:
816
- """Loria/Thermastage: base cooling-related on cooling_state and modulation_level."""
654
+ """Loria/Thermastage: base cooling-related on cooling_state and modulation_level.
655
+
656
+ For the Loria or Thermastage heatpump connected to an Anna cooling-enabled is
657
+ indicated via the Cooling Enable switch in the Plugwise App.
658
+ """
817
659
  # For Loria/Thermastage it's not clear if cooling_enabled in xml shows the correct status,
818
660
  # setting it specifically:
819
- self._cooling_enabled = data["binary_sensors"]["cooling_enabled"] = data["binary_sensors"]["cooling_state"]
661
+ self._cooling_enabled = data["binary_sensors"]["cooling_enabled"] = data[
662
+ "binary_sensors"
663
+ ]["cooling_state"]
820
664
  self._cooling_active = data["sensors"]["modulation_level"] == 100
821
665
  # For Loria the above does not work (pw-beta issue #301)
822
666
  if "cooling_ena_switch" in data["switches"]:
823
- self._cooling_enabled = data["binary_sensors"]["cooling_enabled"] = data["switches"]["cooling_ena_switch"]
667
+ self._cooling_enabled = data["binary_sensors"]["cooling_enabled"] = data[
668
+ "switches"
669
+ ]["cooling_ena_switch"]
824
670
  self._cooling_active = data["binary_sensors"]["cooling_state"]
825
671
 
826
672
  def _cleanup_data(self, data: GwEntityData) -> None:
@@ -870,7 +716,10 @@ class SmileHelper(SmileCommon):
870
716
  "dev_class": "climate",
871
717
  "model": "ThermoZone",
872
718
  "name": loc_data["name"],
873
- "thermostats": {"primary": loc_data["primary"], "secondary": loc_data["secondary"]},
719
+ "thermostats": {
720
+ "primary": loc_data["primary"],
721
+ "secondary": loc_data["secondary"],
722
+ },
874
723
  "vendor": "Plugwise",
875
724
  }
876
725
  self._count += 3
@@ -910,10 +759,12 @@ class SmileHelper(SmileCommon):
910
759
  if thermo_matching[appl_class] == thermo_loc["primary_prio"]:
911
760
  thermo_loc["primary"].append(appliance_id)
912
761
  # Pre-elect new primary
913
- elif (thermo_rank := thermo_matching[appl_class]) > thermo_loc["primary_prio"]:
762
+ elif (thermo_rank := thermo_matching[appl_class]) > thermo_loc[
763
+ "primary_prio"
764
+ ]:
914
765
  thermo_loc["primary_prio"] = thermo_rank
915
766
  # Demote former primary
916
- if (tl_primary := thermo_loc["primary"]):
767
+ if tl_primary := thermo_loc["primary"]:
917
768
  thermo_loc["secondary"] += tl_primary
918
769
  thermo_loc["primary"] = []
919
770
 
@@ -929,11 +780,9 @@ class SmileHelper(SmileCommon):
929
780
  Represents the heating/cooling demand-state of the local primary thermostat.
930
781
  Note: heating or cooling can still be active when the setpoint has been reached.
931
782
  """
932
- locator = f'location[@id="{loc_id}"]'
933
- if (location := self._domain_objects.find(locator)) is not None:
934
- locator = './actuator_functionalities/thermostat_functionality[type="thermostat"]/control_state'
935
- if (ctrl_state := location.find(locator)) is not None:
936
- return str(ctrl_state.text)
783
+ locator = f'location[@id="{loc_id}"]/actuator_functionalities/thermostat_functionality[type="thermostat"]/control_state'
784
+ if (ctrl_state := self._domain_objects.find(locator)) is not None:
785
+ return str(ctrl_state.text)
937
786
 
938
787
  # Handle missing control_state in regulation_mode off for firmware >= 3.2.0 (issue #776)
939
788
  # In newer firmware versions, default to "off" when control_state is not present
@@ -1010,9 +859,17 @@ class SmileHelper(SmileCommon):
1010
859
  for rule in self._domain_objects.findall(f'./rule[name="{name}"]'):
1011
860
  active = rule.find("active").text
1012
861
  if rule.find(locator) is not None:
1013
- schedule_ids[rule.attrib["id"]] = {"location": loc_id, "name": name, "active": active}
862
+ schedule_ids[rule.attrib["id"]] = {
863
+ "location": loc_id,
864
+ "name": name,
865
+ "active": active,
866
+ }
1014
867
  else:
1015
- schedule_ids[rule.attrib["id"]] = {"location": NONE, "name": name, "active": active}
868
+ schedule_ids[rule.attrib["id"]] = {
869
+ "location": NONE,
870
+ "name": name,
871
+ "active": active,
872
+ }
1016
873
 
1017
874
  return schedule_ids
1018
875
 
@@ -1029,9 +886,17 @@ class SmileHelper(SmileCommon):
1029
886
  name = rule.find("name").text
1030
887
  active = rule.find("active").text
1031
888
  if rule.find(locator2) is not None:
1032
- schedule_ids[rule.attrib["id"]] = {"location": loc_id, "name": name, "active": active}
889
+ schedule_ids[rule.attrib["id"]] = {
890
+ "location": loc_id,
891
+ "name": name,
892
+ "active": active,
893
+ }
1033
894
  else:
1034
- schedule_ids[rule.attrib["id"]] = {"location": NONE, "name": name, "active": active}
895
+ schedule_ids[rule.attrib["id"]] = {
896
+ "location": NONE,
897
+ "name": name,
898
+ "active": active,
899
+ }
1035
900
 
1036
901
  return schedule_ids
1037
902