plugwise 1.6.4__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
@@ -5,7 +5,6 @@ Plugwise Smile protocol helpers.
5
5
 
6
6
  from __future__ import annotations
7
7
 
8
- import asyncio
9
8
  import datetime as dt
10
9
  from typing import cast
11
10
 
@@ -38,29 +37,20 @@ from plugwise.constants import (
38
37
  ActuatorData,
39
38
  ActuatorDataType,
40
39
  ActuatorType,
41
- GatewayData,
42
40
  GwEntityData,
43
41
  SensorType,
44
42
  ThermoLoc,
45
43
  ToggleNameType,
46
44
  )
47
- from plugwise.exceptions import (
48
- ConnectionFailedError,
49
- InvalidAuthentication,
50
- InvalidXMLError,
51
- ResponseError,
52
- )
53
45
  from plugwise.util import (
54
46
  check_model,
47
+ collect_power_values,
55
48
  common_match_cases,
56
- escape_illegal_xml_characters,
49
+ count_data_items,
57
50
  format_measure,
58
51
  skip_obsolete_measurements,
59
52
  )
60
53
 
61
- # This way of importing aiohttp is because of patch/mocking in testing (aiohttp timeouts)
62
- from aiohttp import BasicAuth, ClientError, ClientResponse, ClientSession, ClientTimeout
63
-
64
54
  # Time related
65
55
  from dateutil import tz
66
56
  from dateutil.parser import parse
@@ -78,204 +68,25 @@ def search_actuator_functionalities(appliance: etree, actuator: str) -> etree |
78
68
  return None
79
69
 
80
70
 
81
- class SmileComm:
82
- """The SmileComm class."""
83
-
84
- def __init__(
85
- self,
86
- host: str,
87
- password: str,
88
- port: int,
89
- timeout: int,
90
- username: str,
91
- websession: ClientSession | None,
92
- ) -> None:
93
- """Set the constructor for this class."""
94
- if not websession:
95
- aio_timeout = ClientTimeout(total=timeout)
96
-
97
- async def _create_session() -> ClientSession:
98
- return ClientSession(timeout=aio_timeout) # pragma: no cover
99
-
100
- loop = asyncio.get_event_loop()
101
- if loop.is_running():
102
- self._websession = ClientSession(timeout=aio_timeout)
103
- else:
104
- self._websession = loop.run_until_complete(
105
- _create_session()
106
- ) # pragma: no cover
107
- else:
108
- self._websession = websession
109
-
110
- # Quickfix IPv6 formatting, not covering
111
- if host.count(":") > 2: # pragma: no cover
112
- host = f"[{host}]"
113
-
114
- self._auth = BasicAuth(username, password=password)
115
- self._endpoint = f"http://{host}:{str(port)}"
116
-
117
- async def _request(
118
- self,
119
- command: str,
120
- retry: int = 3,
121
- method: str = "get",
122
- data: str | None = None,
123
- headers: dict[str, str] | None = None,
124
- ) -> etree:
125
- """Get/put/delete data from a give URL."""
126
- resp: ClientResponse
127
- url = f"{self._endpoint}{command}"
128
- use_headers = headers
129
-
130
- try:
131
- match method:
132
- case "delete":
133
- resp = await self._websession.delete(url, auth=self._auth)
134
- case "get":
135
- # Work-around for Stretchv2, should not hurt the other smiles
136
- use_headers = {"Accept-Encoding": "gzip"}
137
- resp = await self._websession.get(
138
- url, headers=use_headers, auth=self._auth
139
- )
140
- case "post":
141
- use_headers = {"Content-type": "text/xml"}
142
- resp = await self._websession.post(
143
- url,
144
- headers=use_headers,
145
- data=data,
146
- auth=self._auth,
147
- )
148
- case "put":
149
- use_headers = {"Content-type": "text/xml"}
150
- resp = await self._websession.put(
151
- url,
152
- headers=use_headers,
153
- data=data,
154
- auth=self._auth,
155
- )
156
- except (
157
- ClientError
158
- ) as exc: # ClientError is an ancestor class of ServerTimeoutError
159
- if retry < 1:
160
- LOGGER.warning(
161
- "Failed sending %s %s to Plugwise Smile, error: %s",
162
- method,
163
- command,
164
- exc,
165
- )
166
- raise ConnectionFailedError from exc
167
- return await self._request(command, retry - 1)
168
-
169
- if resp.status == 504:
170
- if retry < 1:
171
- LOGGER.warning(
172
- "Failed sending %s %s to Plugwise Smile, error: %s",
173
- method,
174
- command,
175
- "504 Gateway Timeout",
176
- )
177
- raise ConnectionFailedError
178
- return await self._request(command, retry - 1)
179
-
180
- return await self._request_validate(resp, method)
181
-
182
- async def _request_validate(self, resp: ClientResponse, method: str) -> etree:
183
- """Helper-function for _request(): validate the returned data."""
184
- match resp.status:
185
- case 200:
186
- # Cornercases for server not responding with 202
187
- if method in ("post", "put"):
188
- return
189
- case 202:
190
- # Command accepted gives empty body with status 202
191
- return
192
- case 401:
193
- msg = (
194
- "Invalid Plugwise login, please retry with the correct credentials."
195
- )
196
- LOGGER.error("%s", msg)
197
- raise InvalidAuthentication
198
- case 405:
199
- msg = "405 Method not allowed."
200
- LOGGER.error("%s", msg)
201
- raise ConnectionFailedError
202
-
203
- if not (result := await resp.text()) or (
204
- "<error>" in result and "Not started" not in result
205
- ):
206
- LOGGER.warning("Smile response empty or error in %s", result)
207
- raise ResponseError
208
-
209
- try:
210
- # Encode to ensure utf8 parsing
211
- xml = etree.XML(escape_illegal_xml_characters(result).encode())
212
- except etree.ParseError as exc:
213
- LOGGER.warning("Smile returns invalid XML for %s", self._endpoint)
214
- raise InvalidXMLError from exc
215
-
216
- return xml
217
-
218
- async def close_connection(self) -> None:
219
- """Close the Plugwise connection."""
220
- await self._websession.close()
221
-
222
-
223
71
  class SmileHelper(SmileCommon):
224
72
  """The SmileHelper class."""
225
73
 
226
74
  def __init__(self) -> None:
227
75
  """Set the constructor for this class."""
228
- self._cooling_present: bool
229
- self._count: int
230
- self._dhw_allowed_modes: list[str] = []
231
- self._domain_objects: etree
232
76
  self._endpoint: str
233
77
  self._elga: bool
234
- self._gw_allowed_modes: list[str] = []
235
- self._heater_id: str
236
- self._home_location: str
237
78
  self._is_thermostat: bool
238
79
  self._last_active: dict[str, str | None]
239
- self._last_modified: dict[str, str] = {}
240
80
  self._loc_data: dict[str, ThermoLoc]
241
- self._notifications: dict[str, dict[str, str]] = {}
242
- self._on_off_device: bool
243
- self._opentherm_device: bool
244
- self._reg_allowed_modes: list[str] = []
245
81
  self._schedule_old_states: dict[str, dict[str, str]]
246
- self._status: etree
247
- self._stretch_v2: bool
248
- self._system: etree
249
- self._thermo_locs: dict[str, ThermoLoc] = {}
250
- ###################################################################
251
- # '_cooling_enabled' can refer to the state of the Elga heatpump
252
- # connected to an Anna. For Elga, 'elga_status_code' in (8, 9)
253
- # means cooling mode is available, next to heating mode.
254
- # 'elga_status_code' = 8 means cooling is active, 9 means idle.
255
- #
256
- # '_cooling_enabled' cam refer to the state of the Loria or
257
- # Thermastage heatpump connected to an Anna. For these,
258
- # 'cooling_enabled' = on means set to cooling mode, instead of to
259
- # heating mode.
260
- # 'cooling_state' = on means cooling is active.
261
- ###################################################################
262
- self._cooling_active = False
263
- self._cooling_enabled = False
264
-
265
- self.gateway_id: str
266
- self.gw_data: GatewayData = {}
267
- self.gw_entities: dict[str, GwEntityData] = {}
268
- 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]
269
85
  self.smile_hw_version: str | None
270
86
  self.smile_mac_address: str | None
271
87
  self.smile_model: str
272
88
  self.smile_model_id: str | None
273
- self.smile_name: str
274
- self.smile_type: str
275
89
  self.smile_version: version.Version | None
276
- self.smile_zigbee_mac_address: str | None
277
- self.therms_with_offset_func: list[str] = []
278
- self._zones: dict[str, GwEntityData] = {}
279
90
  SmileCommon.__init__(self)
280
91
 
281
92
  def _all_appliances(self) -> None:
@@ -311,10 +122,10 @@ class SmileHelper(SmileCommon):
311
122
  appl.location = None
312
123
  if (appl_loc := appliance.find("location")) is not None:
313
124
  appl.location = appl_loc.attrib["id"]
314
- # 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,
315
126
  # they are not active
316
127
  elif appl.pwclass not in THERMOSTAT_CLASSES:
317
- appl.location = self._home_location
128
+ appl.location = self._home_loc_id
318
129
 
319
130
  # Don't show orphaned thermostat-types
320
131
  if appl.pwclass in THERMOSTAT_CLASSES and appl.location is None:
@@ -350,22 +161,16 @@ class SmileHelper(SmileCommon):
350
161
  switched to maintain backward compatibility with existing implementations.
351
162
  """
352
163
  appl = Munch()
353
- loc_id = next(iter(self._loc_data.keys()))
354
- if (
355
- location := self._domain_objects.find(f'./location[@id="{loc_id}"]')
356
- ) is None:
357
- return
358
-
359
164
  locator = MODULE_LOCATOR
360
- module_data = self._get_module_data(location, locator)
165
+ module_data = self._get_module_data(self._home_location, locator)
361
166
  if not module_data["contents"]:
362
167
  LOGGER.error("No module data found for SmartMeter") # pragma: no cover
363
168
  return # pragma: no cover
364
169
  appl.available = None
365
- appl.entity_id = self.gateway_id
170
+ appl.entity_id = self._gateway_id
366
171
  appl.firmware = module_data["firmware_version"]
367
172
  appl.hardware = module_data["hardware_version"]
368
- appl.location = loc_id
173
+ appl.location = self._home_loc_id
369
174
  appl.mac = None
370
175
  appl.model = module_data["vendor_model"]
371
176
  appl.model_id = None # don't use model_id for SmartMeter
@@ -375,8 +180,8 @@ class SmileHelper(SmileCommon):
375
180
  appl.zigbee_mac = None
376
181
 
377
182
  # Replace the entity_id of the gateway by the smartmeter location_id
378
- self.gw_entities[loc_id] = self.gw_entities.pop(self.gateway_id)
379
- self.gateway_id = loc_id
183
+ self.gw_entities[self._home_loc_id] = self.gw_entities.pop(self._gateway_id)
184
+ self._gateway_id = self._home_loc_id
380
185
 
381
186
  self._create_gw_entities(appl)
382
187
 
@@ -398,10 +203,14 @@ class SmileHelper(SmileCommon):
398
203
  for location in locations:
399
204
  loc.name = location.find("name").text
400
205
  loc.loc_id = location.attrib["id"]
401
- if loc.name == "Home":
402
- self._home_location = loc.loc_id
403
-
404
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
+ )
405
214
 
406
215
  def _appliance_info_finder(self, appl: Munch, appliance: etree) -> Munch:
407
216
  """Collect info for all appliances found."""
@@ -445,8 +254,8 @@ class SmileHelper(SmileCommon):
445
254
 
446
255
  def _appl_gateway_info(self, appl: Munch, appliance: etree) -> Munch:
447
256
  """Helper-function for _appliance_info_finder()."""
448
- self.gateway_id = appliance.attrib["id"]
449
- appl.firmware = str(self.smile_fw_version)
257
+ self._gateway_id = appliance.attrib["id"]
258
+ appl.firmware = str(self.smile_version)
450
259
  appl.hardware = self.smile_hw_version
451
260
  appl.mac = self.smile_mac_address
452
261
  appl.model = self.smile_model
@@ -485,15 +294,9 @@ class SmileHelper(SmileCommon):
485
294
  ) is not None and (modes := search.find("allowed_modes")) is not None:
486
295
  for mode in modes:
487
296
  mode_list.append(mode.text)
488
- self._check_cooling_mode(mode.text)
489
297
 
490
298
  return mode_list
491
299
 
492
- def _check_cooling_mode(self, mode: str) -> None:
493
- """Check if cooling mode is present and update state."""
494
- if mode == "cooling":
495
- self._cooling_present = True
496
-
497
300
  def _get_appliances_with_offset_functionality(self) -> list[str]:
498
301
  """Helper-function collecting all appliance that have offset_functionality."""
499
302
  therm_list: list[str] = []
@@ -532,7 +335,7 @@ class SmileHelper(SmileCommon):
532
335
  # !! DON'T CHANGE below two if-lines, will break stuff !!
533
336
  if self.smile_type == "power":
534
337
  if entity["dev_class"] == "smartmeter":
535
- data.update(self._power_data_from_location(entity["location"]))
338
+ data.update(self._power_data_from_location())
536
339
 
537
340
  return data
538
341
 
@@ -574,21 +377,20 @@ class SmileHelper(SmileCommon):
574
377
 
575
378
  return data
576
379
 
577
- def _power_data_from_location(self, loc_id: str) -> GwEntityData:
380
+ def _power_data_from_location(self) -> GwEntityData:
578
381
  """Helper-function for smile.py: _get_entity_data().
579
382
 
580
- Collect the power-data based on Location ID, from LOCATIONS.
383
+ Collect the power-data from the Home location.
581
384
  """
582
385
  data: GwEntityData = {"sensors": {}}
583
386
  loc = Munch()
584
387
  log_list: list[str] = ["point_log", "cumulative_log", "interval_log"]
585
388
  t_string = "tariff"
586
389
 
587
- search = self._domain_objects
588
- loc.logs = search.find(f'./location[@id="{loc_id}"]/logs')
390
+ loc.logs = self._home_location.find("./logs")
589
391
  for loc.measurement, loc.attrs in P1_MEASUREMENTS.items():
590
392
  for loc.log_type in log_list:
591
- self._collect_power_values(data, loc, t_string)
393
+ collect_power_values(data, loc, t_string)
592
394
 
593
395
  self._count += len(data["sensors"])
594
396
  return data
@@ -624,7 +426,7 @@ class SmileHelper(SmileCommon):
624
426
  appl_i_loc.text, ENERGY_WATT_HOUR
625
427
  )
626
428
 
627
- self._count_data_items(data)
429
+ self._count = count_data_items(self._count, data)
628
430
 
629
431
  def _get_toggle_state(
630
432
  self, xml: etree, toggle: str, name: ToggleNameType, data: GwEntityData
@@ -647,7 +449,7 @@ class SmileHelper(SmileCommon):
647
449
  msg_id = notification.attrib["id"]
648
450
  msg_type = notification.find("type").text
649
451
  msg = notification.find("message").text
650
- self._notifications.update({msg_id: {msg_type: msg}})
452
+ self._notifications[msg_id] = {msg_type: msg}
651
453
  LOGGER.debug("Plugwise notifications: %s", self._notifications)
652
454
  except AttributeError: # pragma: no cover
653
455
  LOGGER.debug(
@@ -724,7 +526,7 @@ class SmileHelper(SmileCommon):
724
526
 
725
527
  Collect the requested gateway mode.
726
528
  """
727
- if not (self.smile(ADAM) and entity_id == self.gateway_id):
529
+ if not (self.smile(ADAM) and entity_id == self._gateway_id):
728
530
  return None
729
531
 
730
532
  if (search := search_actuator_functionalities(appliance, key)) is not None:
@@ -764,31 +566,14 @@ class SmileHelper(SmileCommon):
764
566
  self._count += 1
765
567
 
766
568
  def _get_gateway_outdoor_temp(self, entity_id: str, data: GwEntityData) -> None:
767
- """Adam & Anna: the Smile outdoor_temperature is present in DOMAIN_OBJECTS and LOCATIONS.
768
-
769
- Available under the Home location.
770
- """
771
- if self._is_thermostat and entity_id == self.gateway_id:
772
- outdoor_temperature = self._object_value(
773
- self._home_location, "outdoor_temperature"
774
- )
775
- if outdoor_temperature is not None:
776
- 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}})
777
575
  self._count += 1
778
576
 
779
- def _object_value(self, obj_id: str, measurement: str) -> float | int | None:
780
- """Helper-function for smile.py: _get_entity_data().
781
-
782
- Obtain the value/state for the given object from a location in DOMAIN_OBJECTS
783
- """
784
- val: float | int | None = None
785
- search = self._domain_objects
786
- locator = f'./location[@id="{obj_id}"]/logs/point_log[type="{measurement}"]/period/measurement'
787
- if (found := search.find(locator)) is not None:
788
- val = format_measure(found.text, NONE)
789
-
790
- return val
791
-
792
577
  def _process_c_heating_state(self, data: GwEntityData) -> None:
793
578
  """Helper-function for _get_measurement_data().
794
579
 
@@ -840,7 +625,11 @@ class SmileHelper(SmileCommon):
840
625
  self._update_loria_cooling(data)
841
626
 
842
627
  def _update_elga_cooling(self, data: GwEntityData) -> None:
843
- """# 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
+ """
844
633
  if data["thermostat_supports_cooling"]:
845
634
  # Techneco Elga has cooling-capability
846
635
  self._cooling_present = True
@@ -862,7 +651,11 @@ class SmileHelper(SmileCommon):
862
651
  self._count -= 1
863
652
 
864
653
  def _update_loria_cooling(self, data: GwEntityData) -> None:
865
- """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
+ """
866
659
  # For Loria/Thermastage it's not clear if cooling_enabled in xml shows the correct status,
867
660
  # setting it specifically:
868
661
  self._cooling_enabled = data["binary_sensors"]["cooling_enabled"] = data[
@@ -987,11 +780,9 @@ class SmileHelper(SmileCommon):
987
780
  Represents the heating/cooling demand-state of the local primary thermostat.
988
781
  Note: heating or cooling can still be active when the setpoint has been reached.
989
782
  """
990
- locator = f'location[@id="{loc_id}"]'
991
- if (location := self._domain_objects.find(locator)) is not None:
992
- locator = './actuator_functionalities/thermostat_functionality[type="thermostat"]/control_state'
993
- if (ctrl_state := location.find(locator)) is not None:
994
- 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)
995
786
 
996
787
  # Handle missing control_state in regulation_mode off for firmware >= 3.2.0 (issue #776)
997
788
  # In newer firmware versions, default to "off" when control_state is not present
plugwise/legacy/data.py CHANGED
@@ -7,7 +7,7 @@ from __future__ import annotations
7
7
 
8
8
  # Dict as class
9
9
  # Version detection
10
- from plugwise.constants import NONE, OFF, GwEntityData
10
+ from plugwise.constants import NONE, OFF, GwEntityData, SmileProps
11
11
  from plugwise.legacy.helper import SmileLegacyHelper
12
12
  from plugwise.util import remove_empty_platform_dicts
13
13
 
@@ -17,25 +17,20 @@ class SmileLegacyData(SmileLegacyHelper):
17
17
 
18
18
  def __init__(self) -> None:
19
19
  """Init."""
20
+ self._smile_props: SmileProps
20
21
  SmileLegacyHelper.__init__(self)
21
22
 
22
23
  def _all_entity_data(self) -> None:
23
24
  """Helper-function for get_all_gateway_entities().
24
25
 
25
- Collect data for each entity and add to self.gw_data and self.gw_entities.
26
+ Collect data for each entity and add to self._smile_props and self.gw_entities.
26
27
  """
27
28
  self._update_gw_entities()
28
- self.gw_data.update(
29
- {
30
- "gateway_id": self.gateway_id,
31
- "item_count": self._count,
32
- "smile_name": self.smile_name,
33
- }
34
- )
29
+ self._smile_props["gateway_id"] = self.gateway_id
30
+ self._smile_props["item_count"] = self._count
31
+ self._smile_props["smile_name"] = self.smile_name
35
32
  if self._is_thermostat:
36
- self.gw_data.update(
37
- {"heater_id": self._heater_id, "cooling_present": False}
38
- )
33
+ self._smile_props["heater_id"] = self._heater_id
39
34
 
40
35
  def _update_gw_entities(self) -> None:
41
36
  """Helper-function for _all_entity_data() and async_update().