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/legacy/helper.py CHANGED
@@ -30,13 +30,14 @@ from plugwise.constants import (
30
30
  ActuatorDataType,
31
31
  ActuatorType,
32
32
  ApplianceType,
33
- GatewayData,
34
33
  GwEntityData,
35
34
  SensorType,
36
35
  ThermoLoc,
37
36
  )
38
37
  from plugwise.util import (
38
+ collect_power_values,
39
39
  common_match_cases,
40
+ count_data_items,
40
41
  format_measure,
41
42
  skip_obsolete_measurements,
42
43
  version_to_model,
@@ -63,32 +64,15 @@ class SmileLegacyHelper(SmileCommon):
63
64
  def __init__(self) -> None:
64
65
  """Set the constructor for this class."""
65
66
  self._appliances: etree
66
- self._count: int
67
- self._domain_objects: etree
68
- self._heater_id: str
69
- self._home_location: str
70
67
  self._is_thermostat: bool
71
- self._last_modified: dict[str, str] = {}
72
68
  self._loc_data: dict[str, ThermoLoc]
73
69
  self._locations: etree
74
70
  self._modules: etree
75
- self._notifications: dict[str, dict[str, str]] = {}
76
- self._on_off_device: bool
77
- self._opentherm_device: bool
78
- self._outdoor_temp: float
79
- self._status: etree
80
71
  self._stretch_v2: bool
81
- self._system: etree
82
-
83
- self.gateway_id: str
84
- self.gw_data: GatewayData = {}
85
72
  self.gw_entities: dict[str, GwEntityData] = {}
86
- self.smile_fw_version: Version | None
87
- self.smile_hw_version: str | None
88
73
  self.smile_mac_address: str | None
89
74
  self.smile_model: str
90
- self.smile_name: str
91
- self.smile_type: str
75
+ self.smile_version: Version | None
92
76
  self.smile_zigbee_mac_address: str | None
93
77
  SmileCommon.__init__(self)
94
78
 
@@ -115,7 +99,7 @@ class SmileLegacyHelper(SmileCommon):
115
99
  ):
116
100
  continue # pragma: no cover
117
101
 
118
- appl.location = self._home_location
102
+ appl.location = self._home_loc_id
119
103
  appl.entity_id = appliance.attrib["id"]
120
104
  appl.name = appliance.find("name").text
121
105
  # Extend device_class name when a Circle/Stealth is type heater_central -- Pw-Beta Issue #739
@@ -161,7 +145,7 @@ class SmileLegacyHelper(SmileCommon):
161
145
 
162
146
  # Legacy Anna without outdoor_temp and Stretches have no locations, create fake location-data
163
147
  if not (locations := self._locations.findall("./location")):
164
- self._home_location = FAKE_LOC
148
+ self._home_loc_id = FAKE_LOC
165
149
  self._loc_data[FAKE_LOC] = {"name": "Home"}
166
150
  return
167
151
 
@@ -174,11 +158,11 @@ class SmileLegacyHelper(SmileCommon):
174
158
  continue
175
159
 
176
160
  if loc.name == "Home":
177
- self._home_location = loc.loc_id
161
+ self._home_loc_id = loc.loc_id
178
162
  # Replace location-name for P1 legacy, can contain privacy-related info
179
163
  if self.smile_type == "power":
180
164
  loc.name = "Home"
181
- self._home_location = loc.loc_id
165
+ self._home_loc_id = loc.loc_id
182
166
 
183
167
  self._loc_data[loc.loc_id] = {"name": loc.name}
184
168
 
@@ -187,15 +171,15 @@ class SmileLegacyHelper(SmileCommon):
187
171
 
188
172
  Use the home_location or FAKE_APPL as entity id.
189
173
  """
190
- self.gateway_id = self._home_location
174
+ self.gateway_id = self._home_loc_id
191
175
  if self.smile_type == "power":
192
176
  self.gateway_id = FAKE_APPL
193
177
 
194
178
  self.gw_entities[self.gateway_id] = {"dev_class": "gateway"}
195
179
  self._count += 1
196
180
  for key, value in {
197
- "firmware": str(self.smile_fw_version),
198
- "location": self._home_location,
181
+ "firmware": str(self.smile_version),
182
+ "location": self._home_loc_id,
199
183
  "mac_address": self.smile_mac_address,
200
184
  "model": self.smile_model,
201
185
  "name": self.smile_name,
@@ -272,7 +256,7 @@ class SmileLegacyHelper(SmileCommon):
272
256
  Collect the appliance-data based on entity_id.
273
257
  """
274
258
  data: GwEntityData = {"binary_sensors": {}, "sensors": {}, "switches": {}}
275
- # Get P1 smartmeter data from LOCATIONS or MODULES
259
+ # Get P1 smartmeter data from MODULES
276
260
  entity = self.gw_entities[entity_id]
277
261
  # !! DON'T CHANGE below two if-lines, will break stuff !!
278
262
  if self.smile_type == "power":
@@ -294,14 +278,13 @@ class SmileLegacyHelper(SmileCommon):
294
278
  if appliance.find("type").text in ACTUATOR_CLASSES:
295
279
  self._get_actuator_functionalities(appliance, entity, data)
296
280
 
297
- # Adam & Anna: the Smile outdoor_temperature is present in DOMAIN_OBJECTS and LOCATIONS - under Home
298
- # The outdoor_temperature present in APPLIANCES is a local sensor connected to the active device
281
+ # Anna: the Smile outdoor_temperature is present in the Home location
282
+ # For some Anna's LOCATIONS is empty, falling back to domain_objects!
299
283
  if self._is_thermostat and entity_id == self.gateway_id:
300
- outdoor_temperature = self._object_value(
301
- self._home_location, "outdoor_temperature"
302
- )
303
- if outdoor_temperature is not None:
304
- data.update({"sensors": {"outdoor_temperature": outdoor_temperature}})
284
+ locator = f"./location[@id='{self._home_loc_id}']/logs/point_log[type='outdoor_temperature']/period/measurement"
285
+ if (found := self._domain_objects.find(locator)) is not None:
286
+ value = format_measure(found.text, NONE)
287
+ data.update({"sensors": {"outdoor_temperature": value}})
305
288
  self._count += 1
306
289
 
307
290
  if "c_heating_state" in data:
@@ -326,7 +309,7 @@ class SmileLegacyHelper(SmileCommon):
326
309
  loc.meas_list = loc.measurement.split("_")
327
310
  for loc.logs in mod_logs:
328
311
  for loc.log_type in mod_list:
329
- self._collect_power_values(data, loc, t_string, legacy=True)
312
+ collect_power_values(data, loc, t_string, legacy=True)
330
313
 
331
314
  self._count += len(data["sensors"])
332
315
  return data
@@ -359,7 +342,7 @@ class SmileLegacyHelper(SmileCommon):
359
342
  appl_i_loc.text, ENERGY_WATT_HOUR
360
343
  )
361
344
 
362
- self._count_data_items(data)
345
+ self._count = count_data_items(self._count, data)
363
346
 
364
347
  def _get_actuator_functionalities(
365
348
  self, xml: etree, entity: GwEntityData, data: GwEntityData
@@ -396,20 +379,6 @@ class SmileLegacyHelper(SmileCommon):
396
379
  act_item = cast(ActuatorType, item)
397
380
  data[act_item] = temp_dict
398
381
 
399
- def _object_value(self, obj_id: str, measurement: str) -> float | int | None:
400
- """Helper-function for smile.py: _get_entity_data().
401
-
402
- Obtain the value/state for the given object from a location in DOMAIN_OBJECTS
403
- """
404
- val: float | int | None = None
405
- search = self._domain_objects
406
- locator = f'./location[@id="{obj_id}"]/logs/point_log[type="{measurement}"]/period/measurement'
407
- if (found := search.find(locator)) is not None:
408
- val = format_measure(found.text, NONE)
409
- return val
410
-
411
- return val
412
-
413
382
  def _preset(self) -> str | None:
414
383
  """Helper-function for smile.py: _climate_data().
415
384
 
plugwise/legacy/smile.py CHANGED
@@ -11,8 +11,6 @@ from typing import Any
11
11
 
12
12
  from plugwise.constants import (
13
13
  APPLIANCES,
14
- DEFAULT_PORT,
15
- DEFAULT_USERNAME,
16
14
  DOMAIN_OBJECTS,
17
15
  LOCATIONS,
18
16
  LOGGER,
@@ -20,46 +18,40 @@ from plugwise.constants import (
20
18
  OFF,
21
19
  REQUIRE_APPLIANCES,
22
20
  RULES,
23
- GatewayData,
24
21
  GwEntityData,
25
- PlugwiseData,
22
+ SmileProps,
26
23
  ThermoLoc,
27
24
  )
28
25
  from plugwise.exceptions import ConnectionFailedError, DataMissingError, PlugwiseError
29
26
  from plugwise.legacy.data import SmileLegacyData
30
27
 
31
- import aiohttp
32
28
  from munch import Munch
33
29
  from packaging.version import Version
34
30
 
35
31
 
36
32
  class SmileLegacyAPI(SmileLegacyData):
37
- """The Plugwise SmileLegacyAPI class."""
33
+ """The Plugwise SmileLegacyAPI helper class for actual Plugwise legacy devices."""
38
34
 
39
35
  # pylint: disable=too-many-instance-attributes, too-many-public-methods
40
36
 
41
37
  def __init__(
42
38
  self,
43
- host: str,
44
- password: str,
45
- request: Callable[..., Awaitable[Any]],
46
- websession: aiohttp.ClientSession,
47
39
  _is_thermostat: bool,
48
40
  _loc_data: dict[str, ThermoLoc],
49
41
  _on_off_device: bool,
50
42
  _opentherm_device: bool,
43
+ _request: Callable[..., Awaitable[Any]],
44
+ _smile_props: SmileProps,
51
45
  _stretch_v2: bool,
52
46
  _target_smile: str,
53
- smile_fw_version: Version | None,
54
47
  smile_hostname: str,
55
48
  smile_hw_version: str | None,
56
49
  smile_mac_address: str | None,
57
50
  smile_model: str,
58
51
  smile_name: str,
59
52
  smile_type: str,
53
+ smile_version: Version | None,
60
54
  smile_zigbee_mac_address: str | None,
61
- port: int = DEFAULT_PORT,
62
- username: str = DEFAULT_USERNAME,
63
55
  ) -> None:
64
56
  """Set the constructor for this class."""
65
57
  self._cooling_present = False
@@ -67,59 +59,55 @@ class SmileLegacyAPI(SmileLegacyData):
67
59
  self._loc_data = _loc_data
68
60
  self._on_off_device = _on_off_device
69
61
  self._opentherm_device = _opentherm_device
62
+ self._request = _request
63
+ self._smile_props = _smile_props
70
64
  self._stretch_v2 = _stretch_v2
71
65
  self._target_smile = _target_smile
72
- self.request = request
73
- self.smile_fw_version = smile_fw_version
74
66
  self.smile_hostname = smile_hostname
75
67
  self.smile_hw_version = smile_hw_version
76
68
  self.smile_mac_address = smile_mac_address
77
69
  self.smile_model = smile_model
78
70
  self.smile_name = smile_name
79
71
  self.smile_type = smile_type
72
+ self.smile_version = smile_version
80
73
  self.smile_zigbee_mac_address = smile_zigbee_mac_address
81
74
  SmileLegacyData.__init__(self)
82
75
 
76
+ self._first_update = True
83
77
  self._previous_day_number: str = "0"
84
78
 
85
79
  async def full_xml_update(self) -> None:
86
- """Perform a first fetch of all XML data, needed for initialization."""
87
- self._domain_objects = await self.request(DOMAIN_OBJECTS)
88
- self._locations = await self.request(LOCATIONS)
89
- self._modules = await self.request(MODULES)
80
+ """Perform a first fetch of the Plugwise server XML data."""
81
+ self._domain_objects = await self._request(DOMAIN_OBJECTS)
82
+ self._locations = await self._request(LOCATIONS)
83
+ self._modules = await self._request(MODULES)
90
84
  # P1 legacy has no appliances
91
85
  if self.smile_type != "power":
92
- self._appliances = await self.request(APPLIANCES)
86
+ self._appliances = await self._request(APPLIANCES)
93
87
 
94
88
  def get_all_gateway_entities(self) -> None:
95
- """Collect the gateway entities from the received raw XML-data.
89
+ """Collect the Plugwise gateway entities and their data and states from the received raw XML-data.
96
90
 
97
- Run this functions once to gather the initial device configuration,
98
- then regularly run async_update() to refresh the device data.
91
+ First, collect all the connected entities and their initial data.
92
+ Collect and add switching- and/or pump-group entities.
93
+ Finally, collect the data and states for each entity.
99
94
  """
100
- # Gather all the devices and their initial data
101
95
  self._all_appliances()
102
-
103
- # Collect and add switching- and/or pump-group devices
104
96
  if group_data := self._get_group_switches():
105
97
  self.gw_entities.update(group_data)
106
98
 
107
- # Collect the remaining data for all entities
108
99
  self._all_entity_data()
109
100
 
110
- async def async_update(self) -> PlugwiseData:
111
- """Perform an incremental update for updating the various device states."""
112
- # Perform a full update at day-change
101
+ async def async_update(self) -> dict[str, GwEntityData]:
102
+ """Perform an full update update at day-change: re-collect all gateway entities and their data and states.
103
+
104
+ Otherwise perform an incremental update: only collect the entities updated data and states.
105
+ """
113
106
  day_number = dt.datetime.now().strftime("%w")
114
- if (
115
- day_number # pylint: disable=consider-using-assignment-expr
116
- != self._previous_day_number
117
- ):
107
+ if self._first_update or day_number != self._previous_day_number:
118
108
  LOGGER.info(
119
109
  "Performing daily full-update, reload the Plugwise integration when a single entity becomes unavailable."
120
110
  )
121
- self.gw_data: GatewayData = {}
122
- self.gw_entities: dict[str, GwEntityData] = {}
123
111
  try:
124
112
  await self.full_xml_update()
125
113
  self.get_all_gateway_entities()
@@ -129,15 +117,14 @@ class SmileLegacyAPI(SmileLegacyData):
129
117
  raise DataMissingError(
130
118
  "No (full) Plugwise legacy data received"
131
119
  ) from err
132
- # Otherwise perform an incremental update
133
120
  else:
134
121
  try:
135
- self._domain_objects = await self.request(DOMAIN_OBJECTS)
122
+ self._domain_objects = await self._request(DOMAIN_OBJECTS)
136
123
  match self._target_smile:
137
124
  case "smile_v2":
138
- self._modules = await self.request(MODULES)
125
+ self._modules = await self._request(MODULES)
139
126
  case self._target_smile if self._target_smile in REQUIRE_APPLIANCES:
140
- self._appliances = await self.request(APPLIANCES)
127
+ self._appliances = await self._request(APPLIANCES)
141
128
 
142
129
  self._update_gw_entities()
143
130
  # Detect failed data-retrieval
@@ -145,11 +132,9 @@ class SmileLegacyAPI(SmileLegacyData):
145
132
  except KeyError as err: # pragma: no cover
146
133
  raise DataMissingError("No legacy Plugwise data received") from err
147
134
 
135
+ self._first_update = False
148
136
  self._previous_day_number = day_number
149
- return PlugwiseData(
150
- devices=self.gw_entities,
151
- gateway=self.gw_data,
152
- )
137
+ return self.gw_entities
153
138
 
154
139
  ########################################################################################################
155
140
  ### API Set and HA Service-related Functions ###
@@ -309,6 +294,6 @@ class SmileLegacyAPI(SmileLegacyData):
309
294
  method: str = kwargs["method"]
310
295
  data: str | None = kwargs.get("data")
311
296
  try:
312
- await self.request(uri, method=method, data=data)
297
+ await self._request(uri, method=method, data=data)
313
298
  except ConnectionFailedError as exc:
314
299
  raise ConnectionFailedError from exc
plugwise/smile.py CHANGED
@@ -13,8 +13,6 @@ from plugwise.constants import (
13
13
  ADAM,
14
14
  ANNA,
15
15
  APPLIANCES,
16
- DEFAULT_PORT,
17
- DEFAULT_USERNAME,
18
16
  DOMAIN_OBJECTS,
19
17
  GATEWAY_REBOOT,
20
18
  LOCATIONS,
@@ -23,15 +21,13 @@ from plugwise.constants import (
23
21
  NOTIFICATIONS,
24
22
  OFF,
25
23
  RULES,
26
- GatewayData,
27
24
  GwEntityData,
28
- PlugwiseData,
25
+ SmileProps,
29
26
  ThermoLoc,
30
27
  )
31
28
  from plugwise.data import SmileData
32
29
  from plugwise.exceptions import ConnectionFailedError, DataMissingError, PlugwiseError
33
30
 
34
- import aiohttp
35
31
  from defusedxml import ElementTree as etree
36
32
 
37
33
  # Dict as class
@@ -46,10 +42,6 @@ class SmileAPI(SmileData):
46
42
 
47
43
  def __init__(
48
44
  self,
49
- host: str,
50
- password: str,
51
- request: Callable[..., Awaitable[Any]],
52
- websession: aiohttp.ClientSession,
53
45
  _cooling_present: bool,
54
46
  _elga: bool,
55
47
  _is_thermostat: bool,
@@ -57,9 +49,9 @@ class SmileAPI(SmileData):
57
49
  _loc_data: dict[str, ThermoLoc],
58
50
  _on_off_device: bool,
59
51
  _opentherm_device: bool,
52
+ _request: Callable[..., Awaitable[Any]],
60
53
  _schedule_old_states: dict[str, dict[str, str]],
61
- gateway_id: str,
62
- smile_fw_version: Version | None,
54
+ _smile_props: SmileProps,
63
55
  smile_hostname: str | None,
64
56
  smile_hw_version: str | None,
65
57
  smile_mac_address: str | None,
@@ -68,23 +60,18 @@ class SmileAPI(SmileData):
68
60
  smile_name: str,
69
61
  smile_type: str,
70
62
  smile_version: Version | None,
71
- port: int = DEFAULT_PORT,
72
- username: str = DEFAULT_USERNAME,
73
63
  ) -> None:
74
64
  """Set the constructor for this class."""
75
- self._cooling_enabled = False
76
65
  self._cooling_present = _cooling_present
77
66
  self._elga = _elga
78
- self._heater_id: str
79
67
  self._is_thermostat = _is_thermostat
80
68
  self._last_active = _last_active
81
69
  self._loc_data = _loc_data
82
70
  self._on_off_device = _on_off_device
83
71
  self._opentherm_device = _opentherm_device
72
+ self._request = _request
84
73
  self._schedule_old_states = _schedule_old_states
85
- self.gateway_id = gateway_id
86
- self.request = request
87
- self.smile_fw_version = smile_fw_version
74
+ self._smile_props = _smile_props
88
75
  self.smile_hostname = smile_hostname
89
76
  self.smile_hw_version = smile_hw_version
90
77
  self.smile_mac_address = smile_mac_address
@@ -93,48 +80,49 @@ class SmileAPI(SmileData):
93
80
  self.smile_name = smile_name
94
81
  self.smile_type = smile_type
95
82
  self.smile_version = smile_version
83
+ self.therms_with_offset_func: list[str] = []
96
84
  SmileData.__init__(self)
97
85
 
98
86
  async def full_xml_update(self) -> None:
99
- """Perform a first fetch of all XML data, needed for initialization."""
100
- self._domain_objects = await self.request(DOMAIN_OBJECTS)
87
+ """Perform a first fetch of the Plugwise server XML data."""
88
+ self._domain_objects = await self._request(DOMAIN_OBJECTS)
101
89
  self._get_plugwise_notifications()
102
90
 
103
91
  def get_all_gateway_entities(self) -> None:
104
- """Collect the gateway entities from the received raw XML-data.
92
+ """Collect the Plugwise gateway entities and their data and states from the received raw XML-data.
105
93
 
106
- Run this functions once to gather the initial configuration,
107
- then regularly run async_update() to refresh the entity data.
94
+ First, collect all the connected entities and their initial data.
95
+ If a thermostat-gateway, collect a list of thermostats with offset-capability.
96
+ Collect and add switching- and/or pump-group entities.
97
+ Finally, collect the data and states for each entity.
108
98
  """
109
- # Gather all the entities and their initial data
110
99
  self._all_appliances()
111
100
  if self._is_thermostat:
112
- if self.smile(ADAM):
113
- self._scan_thermostats()
114
- # Collect a list of thermostats with offset-capability
115
101
  self.therms_with_offset_func = (
116
102
  self._get_appliances_with_offset_functionality()
117
103
  )
104
+ if self.smile(ADAM):
105
+ self._scan_thermostats()
118
106
 
119
- # Collect and add switching- and/or pump-group devices
120
107
  if group_data := self._get_group_switches():
121
108
  self.gw_entities.update(group_data)
122
109
 
123
- # Collect the remaining data for all entities
124
110
  self._all_entity_data()
125
111
 
126
- async def async_update(self) -> PlugwiseData:
127
- """Perform an incremental update for updating the various device states."""
128
- self.gw_data: GatewayData = {}
129
- self.gw_entities: dict[str, GwEntityData] = {}
130
- self._zones: dict[str, GwEntityData] = {}
112
+ async def async_update(self) -> dict[str, GwEntityData]:
113
+ """Perform an full update: re-collect all gateway entities and their data and states.
114
+
115
+ Any change in the connected entities will be detected immediately.
116
+ """
117
+ self._zones = {}
118
+ self.gw_entities = {}
131
119
  try:
132
120
  await self.full_xml_update()
133
121
  self.get_all_gateway_entities()
134
- # Set self._cooling_enabled - required for set_temperature,
122
+ # Set self._cooling_enabled - required for set_temperature(),
135
123
  # also, check for a failed data-retrieval
136
- if "heater_id" in self.gw_data:
137
- heat_cooler = self.gw_entities[self.gw_data["heater_id"]]
124
+ if "heater_id" in self._smile_props:
125
+ heat_cooler = self.gw_entities[self._smile_props["heater_id"]]
138
126
  if (
139
127
  "binary_sensors" in heat_cooler
140
128
  and "cooling_enabled" in heat_cooler["binary_sensors"]
@@ -143,14 +131,11 @@ class SmileAPI(SmileData):
143
131
  "cooling_enabled"
144
132
  ]
145
133
  else: # cover failed data-retrieval for P1
146
- _ = self.gw_entities[self.gateway_id]["location"]
134
+ _ = self.gw_entities[self._smile_props["gateway_id"]]["location"]
147
135
  except KeyError as err:
148
136
  raise DataMissingError("No Plugwise actual data received") from err
149
137
 
150
- return PlugwiseData(
151
- devices=self.gw_entities,
152
- gateway=self.gw_data,
153
- )
138
+ return self.gw_entities
154
139
 
155
140
  ########################################################################################################
156
141
  ### API Set and HA Service-related Functions ###
@@ -274,7 +259,7 @@ class SmileAPI(SmileData):
274
259
  vacation_time = time_2 + "T23:00:00.000Z"
275
260
  valid = f"<valid_from>{vacation_time}</valid_from><valid_to>{end_time}</valid_to>"
276
261
 
277
- uri = f"{APPLIANCES};id={self.gateway_id}/gateway_mode_control"
262
+ uri = f"{APPLIANCES};id={self._smile_props['gateway_id']}/gateway_mode_control"
278
263
  data = f"<gateway_mode_control_functionality><mode>{mode}</mode>{valid}</gateway_mode_control_functionality>"
279
264
 
280
265
  await self.call_request(uri, method="put", data=data)
@@ -476,6 +461,6 @@ class SmileAPI(SmileData):
476
461
  method: str = kwargs["method"]
477
462
  data: str | None = kwargs.get("data")
478
463
  try:
479
- await self.request(uri, method=method, data=data)
464
+ await self._request(uri, method=method, data=data)
480
465
  except ConnectionFailedError as exc:
481
466
  raise ConnectionFailedError from exc
plugwise/smilecomm.py ADDED
@@ -0,0 +1,148 @@
1
+ """Use of this source code is governed by the MIT license found in the LICENSE file.
2
+
3
+ Plugwise Smile communication protocol helpers.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from plugwise.constants import LOGGER
9
+ from plugwise.exceptions import (
10
+ ConnectionFailedError,
11
+ InvalidAuthentication,
12
+ InvalidXMLError,
13
+ ResponseError,
14
+ )
15
+ from plugwise.util import escape_illegal_xml_characters
16
+
17
+ # This way of importing aiohttp is because of patch/mocking in testing (aiohttp timeouts)
18
+ from aiohttp import BasicAuth, ClientError, ClientResponse, ClientSession, ClientTimeout
19
+ from defusedxml import ElementTree as etree
20
+
21
+
22
+ class SmileComm:
23
+ """The SmileComm class."""
24
+
25
+ def __init__(
26
+ self,
27
+ host: str,
28
+ password: str,
29
+ port: int,
30
+ timeout: int,
31
+ username: str,
32
+ websession: ClientSession | None,
33
+ ) -> None:
34
+ """Set the constructor for this class."""
35
+ if not websession:
36
+ aio_timeout = ClientTimeout(total=timeout)
37
+ self._websession = ClientSession(timeout=aio_timeout)
38
+ else:
39
+ self._websession = websession
40
+
41
+ # Quickfix IPv6 formatting, not covering
42
+ if host.count(":") > 2: # pragma: no cover
43
+ host = f"[{host}]"
44
+
45
+ self._auth = BasicAuth(username, password=password)
46
+ self._endpoint = f"http://{host}:{str(port)}" # Sensitive
47
+
48
+ async def _request(
49
+ self,
50
+ command: str,
51
+ retry: int = 3,
52
+ method: str = "get",
53
+ data: str | None = None,
54
+ ) -> etree:
55
+ """Get/put/delete data from a give URL."""
56
+ resp: ClientResponse
57
+ url = f"{self._endpoint}{command}"
58
+ try:
59
+ match method:
60
+ case "delete":
61
+ resp = await self._websession.delete(url, auth=self._auth)
62
+ case "get":
63
+ # Work-around for Stretchv2, should not hurt the other smiles
64
+ headers = {"Accept-Encoding": "gzip"}
65
+ resp = await self._websession.get(
66
+ url, headers=headers, auth=self._auth
67
+ )
68
+ case "post":
69
+ headers = {"Content-type": "text/xml"}
70
+ resp = await self._websession.post(
71
+ url,
72
+ headers=headers,
73
+ data=data,
74
+ auth=self._auth,
75
+ )
76
+ case "put":
77
+ headers = {"Content-type": "text/xml"}
78
+ resp = await self._websession.put(
79
+ url,
80
+ headers=headers,
81
+ data=data,
82
+ auth=self._auth,
83
+ )
84
+ except (
85
+ ClientError
86
+ ) as exc: # ClientError is an ancestor class of ServerTimeoutError
87
+ if retry < 1:
88
+ LOGGER.warning(
89
+ "Failed sending %s %s to Plugwise Smile, error: %s",
90
+ method,
91
+ command,
92
+ exc,
93
+ )
94
+ raise ConnectionFailedError from exc
95
+ return await self._request(command, retry - 1)
96
+
97
+ if resp.status == 504:
98
+ if retry < 1:
99
+ LOGGER.warning(
100
+ "Failed sending %s %s to Plugwise Smile, error: %s",
101
+ method,
102
+ command,
103
+ "504 Gateway Timeout",
104
+ )
105
+ raise ConnectionFailedError
106
+ return await self._request(command, retry - 1)
107
+
108
+ return await self._request_validate(resp, method)
109
+
110
+ async def _request_validate(self, resp: ClientResponse, method: str) -> etree:
111
+ """Helper-function for _request(): validate the returned data."""
112
+ match resp.status:
113
+ case 200:
114
+ # Cornercases for server not responding with 202
115
+ if method in ("post", "put"):
116
+ return
117
+ case 202:
118
+ # Command accepted gives empty body with status 202
119
+ return
120
+ case 401:
121
+ msg = (
122
+ "Invalid Plugwise login, please retry with the correct credentials."
123
+ )
124
+ LOGGER.error("%s", msg)
125
+ raise InvalidAuthentication
126
+ case 405:
127
+ msg = "405 Method not allowed."
128
+ LOGGER.error("%s", msg)
129
+ raise ConnectionFailedError
130
+
131
+ if not (result := await resp.text()) or (
132
+ "<error>" in result and "Not started" not in result
133
+ ):
134
+ LOGGER.warning("Smile response empty or error in %s", result)
135
+ raise ResponseError
136
+
137
+ try:
138
+ # Encode to ensure utf8 parsing
139
+ xml = etree.XML(escape_illegal_xml_characters(result).encode())
140
+ except etree.ParseError as exc:
141
+ LOGGER.warning("Smile returns invalid XML for %s", self._endpoint)
142
+ raise InvalidXMLError from exc
143
+
144
+ return xml
145
+
146
+ async def close_connection(self) -> None:
147
+ """Close the Plugwise connection."""
148
+ await self._websession.close()