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/legacy/data.py CHANGED
@@ -2,11 +2,12 @@
2
2
 
3
3
  Plugwise Smile protocol data-collection helpers for legacy devices.
4
4
  """
5
+
5
6
  from __future__ import annotations
6
7
 
7
8
  # Dict as class
8
9
  # Version detection
9
- from plugwise.constants import NONE, OFF, GwEntityData
10
+ from plugwise.constants import NONE, OFF, GwEntityData, SmileProps
10
11
  from plugwise.legacy.helper import SmileLegacyHelper
11
12
  from plugwise.util import remove_empty_platform_dicts
12
13
 
@@ -16,25 +17,20 @@ class SmileLegacyData(SmileLegacyHelper):
16
17
 
17
18
  def __init__(self) -> None:
18
19
  """Init."""
20
+ self._smile_props: SmileProps
19
21
  SmileLegacyHelper.__init__(self)
20
22
 
21
23
  def _all_entity_data(self) -> None:
22
24
  """Helper-function for get_all_gateway_entities().
23
25
 
24
- 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.
25
27
  """
26
28
  self._update_gw_entities()
27
- self.gw_data.update(
28
- {
29
- "gateway_id": self.gateway_id,
30
- "item_count": self._count,
31
- "smile_name": self.smile_name,
32
- }
33
- )
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
34
32
  if self._is_thermostat:
35
- self.gw_data.update(
36
- {"heater_id": self._heater_id, "cooling_present": False}
37
- )
33
+ self._smile_props["heater_id"] = self._heater_id
38
34
 
39
35
  def _update_gw_entities(self) -> None:
40
36
  """Helper-function for _all_entity_data() and async_update().
@@ -63,6 +59,7 @@ class SmileLegacyData(SmileLegacyHelper):
63
59
 
64
60
  # Thermostat data (presets, temperatures etc)
65
61
  self._climate_data(entity, data)
62
+ self._get_anna_control_state(data)
66
63
 
67
64
  return data
68
65
 
@@ -91,3 +88,14 @@ class SmileLegacyData(SmileLegacyHelper):
91
88
  self._count += 1
92
89
  if sel_schedule in (NONE, OFF):
93
90
  data["climate_mode"] = "heat"
91
+
92
+ def _get_anna_control_state(self, data: GwEntityData) -> None:
93
+ """Set the thermostat control_state based on the opentherm/onoff device state."""
94
+ data["control_state"] = "idle"
95
+ for entity in self.gw_entities.values():
96
+ if entity["dev_class"] != "heater_central":
97
+ continue
98
+
99
+ binary_sensors = entity["binary_sensors"]
100
+ if binary_sensors["heating_state"]:
101
+ data["control_state"] = "heating"
plugwise/legacy/helper.py CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  Plugwise Smile protocol helpers.
4
4
  """
5
+
5
6
  from __future__ import annotations
6
7
 
7
8
  from typing import cast
@@ -29,13 +30,14 @@ from plugwise.constants import (
29
30
  ActuatorDataType,
30
31
  ActuatorType,
31
32
  ApplianceType,
32
- GatewayData,
33
33
  GwEntityData,
34
34
  SensorType,
35
35
  ThermoLoc,
36
36
  )
37
37
  from plugwise.util import (
38
+ collect_power_values,
38
39
  common_match_cases,
40
+ count_data_items,
39
41
  format_measure,
40
42
  skip_obsolete_measurements,
41
43
  version_to_model,
@@ -62,32 +64,15 @@ class SmileLegacyHelper(SmileCommon):
62
64
  def __init__(self) -> None:
63
65
  """Set the constructor for this class."""
64
66
  self._appliances: etree
65
- self._count: int
66
- self._domain_objects: etree
67
- self._heater_id: str
68
- self._home_location: str
69
67
  self._is_thermostat: bool
70
- self._last_modified: dict[str, str] = {}
71
68
  self._loc_data: dict[str, ThermoLoc]
72
69
  self._locations: etree
73
70
  self._modules: etree
74
- self._notifications: dict[str, dict[str, str]] = {}
75
- self._on_off_device: bool
76
- self._opentherm_device: bool
77
- self._outdoor_temp: float
78
- self._status: etree
79
71
  self._stretch_v2: bool
80
- self._system: etree
81
-
82
- self.gateway_id: str
83
- self.gw_data: GatewayData = {}
84
72
  self.gw_entities: dict[str, GwEntityData] = {}
85
- self.smile_fw_version: Version | None
86
- self.smile_hw_version: str | None
87
73
  self.smile_mac_address: str | None
88
74
  self.smile_model: str
89
- self.smile_name: str
90
- self.smile_type: str
75
+ self.smile_version: Version | None
91
76
  self.smile_zigbee_mac_address: str | None
92
77
  SmileCommon.__init__(self)
93
78
 
@@ -114,7 +99,7 @@ class SmileLegacyHelper(SmileCommon):
114
99
  ):
115
100
  continue # pragma: no cover
116
101
 
117
- appl.location = self._home_location
102
+ appl.location = self._home_loc_id
118
103
  appl.entity_id = appliance.attrib["id"]
119
104
  appl.name = appliance.find("name").text
120
105
  # Extend device_class name when a Circle/Stealth is type heater_central -- Pw-Beta Issue #739
@@ -160,7 +145,7 @@ class SmileLegacyHelper(SmileCommon):
160
145
 
161
146
  # Legacy Anna without outdoor_temp and Stretches have no locations, create fake location-data
162
147
  if not (locations := self._locations.findall("./location")):
163
- self._home_location = FAKE_LOC
148
+ self._home_loc_id = FAKE_LOC
164
149
  self._loc_data[FAKE_LOC] = {"name": "Home"}
165
150
  return
166
151
 
@@ -169,18 +154,15 @@ class SmileLegacyHelper(SmileCommon):
169
154
  loc.loc_id = location.attrib["id"]
170
155
  # Filter the valid single location for P1 legacy: services not empty
171
156
  locator = "./services"
172
- if (
173
- self.smile_type == "power"
174
- and len(location.find(locator)) == 0
175
- ):
157
+ if self.smile_type == "power" and len(location.find(locator)) == 0:
176
158
  continue
177
159
 
178
160
  if loc.name == "Home":
179
- self._home_location = loc.loc_id
161
+ self._home_loc_id = loc.loc_id
180
162
  # Replace location-name for P1 legacy, can contain privacy-related info
181
163
  if self.smile_type == "power":
182
164
  loc.name = "Home"
183
- self._home_location = loc.loc_id
165
+ self._home_loc_id = loc.loc_id
184
166
 
185
167
  self._loc_data[loc.loc_id] = {"name": loc.name}
186
168
 
@@ -189,15 +171,15 @@ class SmileLegacyHelper(SmileCommon):
189
171
 
190
172
  Use the home_location or FAKE_APPL as entity id.
191
173
  """
192
- self.gateway_id = self._home_location
174
+ self.gateway_id = self._home_loc_id
193
175
  if self.smile_type == "power":
194
176
  self.gateway_id = FAKE_APPL
195
177
 
196
178
  self.gw_entities[self.gateway_id] = {"dev_class": "gateway"}
197
179
  self._count += 1
198
180
  for key, value in {
199
- "firmware": str(self.smile_fw_version),
200
- "location": self._home_location,
181
+ "firmware": str(self.smile_version),
182
+ "location": self._home_loc_id,
201
183
  "mac_address": self.smile_mac_address,
202
184
  "model": self.smile_model,
203
185
  "name": self.smile_name,
@@ -212,15 +194,15 @@ class SmileLegacyHelper(SmileCommon):
212
194
  def _appliance_info_finder(self, appliance: etree, appl: Munch) -> Munch:
213
195
  """Collect entity info (Smile/Stretch, Thermostats, OpenTherm/On-Off): firmware, model and vendor name."""
214
196
  match appl.pwclass:
215
- # Collect thermostat entity info
197
+ # Collect thermostat entity info
216
198
  case _ as dev_class if dev_class in THERMOSTAT_CLASSES:
217
199
  return self._appl_thermostat_info(appl, appliance, self._modules)
218
- # Collect heater_central entity info
200
+ # Collect heater_central entity info
219
201
  case "heater_central":
220
202
  return self._appl_heater_central_info(
221
203
  appl, appliance, True, self._appliances, self._modules
222
204
  ) # True means legacy device
223
- # Collect info from Stretches
205
+ # Collect info from Stretches
224
206
  case _:
225
207
  return self._energy_entity_info_finder(appliance, appl)
226
208
 
@@ -231,7 +213,9 @@ class SmileLegacyHelper(SmileCommon):
231
213
  """
232
214
  if self.smile_type in ("power", "stretch"):
233
215
  locator = "./services/electricity_point_meter"
234
- module_data = self._get_module_data(appliance, locator, self._modules, legacy=True)
216
+ module_data = self._get_module_data(
217
+ appliance, locator, self._modules, legacy=True
218
+ )
235
219
  appl.zigbee_mac = module_data["zigbee_mac_address"]
236
220
  # Filter appliance without zigbee_mac, it's an orphaned device
237
221
  if appl.zigbee_mac is None and self.smile_type != "power":
@@ -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,13 +342,10 @@ 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
- self,
366
- xml: etree,
367
- entity: GwEntityData,
368
- data: GwEntityData
348
+ self, xml: etree, entity: GwEntityData, data: GwEntityData
369
349
  ) -> None:
370
350
  """Helper-function for _get_measurement_data()."""
371
351
  for item in ACTIVE_ACTUATORS:
@@ -399,20 +379,6 @@ class SmileLegacyHelper(SmileCommon):
399
379
  act_item = cast(ActuatorType, item)
400
380
  data[act_item] = temp_dict
401
381
 
402
- def _object_value(self, obj_id: str, measurement: str) -> float | int | None:
403
- """Helper-function for smile.py: _get_entity_data().
404
-
405
- Obtain the value/state for the given object from a location in DOMAIN_OBJECTS
406
- """
407
- val: float | int | None = None
408
- search = self._domain_objects
409
- locator = f'./location[@id="{obj_id}"]/logs/point_log[type="{measurement}"]/period/measurement'
410
- if (found := search.find(locator)) is not None:
411
- val = format_measure(found.text, NONE)
412
- return val
413
-
414
- return val
415
-
416
382
  def _preset(self) -> str | None:
417
383
  """Helper-function for smile.py: _climate_data().
418
384
 
@@ -458,7 +424,9 @@ class SmileLegacyHelper(SmileCommon):
458
424
  active = result.text == "on"
459
425
 
460
426
  # Show an empty schedule as no schedule found
461
- directives = search.find(f'./rule[@id="{rule_id}"]/directives/when/then') is not None
427
+ directives = (
428
+ search.find(f'./rule[@id="{rule_id}"]/directives/when/then') is not None
429
+ )
462
430
  if directives and name is not None:
463
431
  available = [name, OFF]
464
432
  selected = name if active else OFF
plugwise/legacy/smile.py CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  Plugwise backend module for Home Assistant Core - covering the legacy P1, Anna, and Stretch devices.
4
4
  """
5
+
5
6
  from __future__ import annotations
6
7
 
7
8
  from collections.abc import Awaitable, Callable
@@ -10,8 +11,6 @@ from typing import Any
10
11
 
11
12
  from plugwise.constants import (
12
13
  APPLIANCES,
13
- DEFAULT_PORT,
14
- DEFAULT_USERNAME,
15
14
  DOMAIN_OBJECTS,
16
15
  LOCATIONS,
17
16
  LOGGER,
@@ -19,46 +18,40 @@ from plugwise.constants import (
19
18
  OFF,
20
19
  REQUIRE_APPLIANCES,
21
20
  RULES,
22
- GatewayData,
23
21
  GwEntityData,
24
- PlugwiseData,
22
+ SmileProps,
25
23
  ThermoLoc,
26
24
  )
27
- from plugwise.exceptions import ConnectionFailedError, PlugwiseError
25
+ from plugwise.exceptions import ConnectionFailedError, DataMissingError, PlugwiseError
28
26
  from plugwise.legacy.data import SmileLegacyData
29
27
 
30
- import aiohttp
31
28
  from munch import Munch
32
29
  from packaging.version import Version
33
30
 
34
31
 
35
32
  class SmileLegacyAPI(SmileLegacyData):
36
- """The Plugwise SmileLegacyAPI class."""
33
+ """The Plugwise SmileLegacyAPI helper class for actual Plugwise legacy devices."""
37
34
 
38
35
  # pylint: disable=too-many-instance-attributes, too-many-public-methods
39
36
 
40
37
  def __init__(
41
38
  self,
42
- host: str,
43
- password: str,
44
- request: Callable[..., Awaitable[Any]],
45
- websession: aiohttp.ClientSession,
46
39
  _is_thermostat: bool,
47
40
  _loc_data: dict[str, ThermoLoc],
48
41
  _on_off_device: bool,
49
42
  _opentherm_device: bool,
43
+ _request: Callable[..., Awaitable[Any]],
44
+ _smile_props: SmileProps,
50
45
  _stretch_v2: bool,
51
46
  _target_smile: str,
52
- smile_fw_version: Version | None,
53
47
  smile_hostname: str,
54
48
  smile_hw_version: str | None,
55
49
  smile_mac_address: str | None,
56
50
  smile_model: str,
57
51
  smile_name: str,
58
52
  smile_type: str,
53
+ smile_version: Version | None,
59
54
  smile_zigbee_mac_address: str | None,
60
- port: int = DEFAULT_PORT,
61
- username: str = DEFAULT_USERNAME,
62
55
  ) -> None:
63
56
  """Set the constructor for this class."""
64
57
  self._cooling_present = False
@@ -66,81 +59,86 @@ class SmileLegacyAPI(SmileLegacyData):
66
59
  self._loc_data = _loc_data
67
60
  self._on_off_device = _on_off_device
68
61
  self._opentherm_device = _opentherm_device
62
+ self._request = _request
63
+ self._smile_props = _smile_props
69
64
  self._stretch_v2 = _stretch_v2
70
65
  self._target_smile = _target_smile
71
- self.request = request
72
- self.smile_fw_version = smile_fw_version
73
66
  self.smile_hostname = smile_hostname
74
67
  self.smile_hw_version = smile_hw_version
75
68
  self.smile_mac_address = smile_mac_address
76
69
  self.smile_model = smile_model
77
70
  self.smile_name = smile_name
78
71
  self.smile_type = smile_type
72
+ self.smile_version = smile_version
79
73
  self.smile_zigbee_mac_address = smile_zigbee_mac_address
80
74
  SmileLegacyData.__init__(self)
81
75
 
76
+ self._first_update = True
82
77
  self._previous_day_number: str = "0"
83
78
 
84
79
  async def full_xml_update(self) -> None:
85
- """Perform a first fetch of all XML data, needed for initialization."""
86
- self._domain_objects = await self.request(DOMAIN_OBJECTS)
87
- self._locations = await self.request(LOCATIONS)
88
- 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)
89
84
  # P1 legacy has no appliances
90
85
  if self.smile_type != "power":
91
- self._appliances = await self.request(APPLIANCES)
86
+ self._appliances = await self._request(APPLIANCES)
92
87
 
93
88
  def get_all_gateway_entities(self) -> None:
94
- """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.
95
90
 
96
- Run this functions once to gather the initial device configuration,
97
- 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.
98
94
  """
99
- # Gather all the devices and their initial data
100
95
  self._all_appliances()
101
-
102
- # Collect and add switching- and/or pump-group devices
103
96
  if group_data := self._get_group_switches():
104
97
  self.gw_entities.update(group_data)
105
98
 
106
- # Collect the remaining data for all entities
107
99
  self._all_entity_data()
108
100
 
109
- async def async_update(self) -> PlugwiseData:
110
- """Perform an incremental update for updating the various device states."""
111
- # 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
+ """
112
106
  day_number = dt.datetime.now().strftime("%w")
113
- if (
114
- day_number # pylint: disable=consider-using-assignment-expr
115
- != self._previous_day_number
116
- ):
107
+ if self._first_update or day_number != self._previous_day_number:
117
108
  LOGGER.info(
118
109
  "Performing daily full-update, reload the Plugwise integration when a single entity becomes unavailable."
119
110
  )
120
- self.gw_data: GatewayData = {}
121
- self.gw_entities: dict[str, GwEntityData] = {}
122
- await self.full_xml_update()
123
- self.get_all_gateway_entities()
124
- # Otherwise perform an incremental update
111
+ try:
112
+ await self.full_xml_update()
113
+ self.get_all_gateway_entities()
114
+ # Detect failed data-retrieval
115
+ _ = self.gw_entities[self.gateway_id]["location"]
116
+ except KeyError as err: # pragma: no cover
117
+ raise DataMissingError(
118
+ "No (full) Plugwise legacy data received"
119
+ ) from err
125
120
  else:
126
- self._domain_objects = await self.request(DOMAIN_OBJECTS)
127
- match self._target_smile:
128
- case "smile_v2":
129
- self._modules = await self.request(MODULES)
130
- case self._target_smile if self._target_smile in REQUIRE_APPLIANCES:
131
- self._appliances = await self.request(APPLIANCES)
132
-
133
- self._update_gw_entities()
134
-
121
+ try:
122
+ self._domain_objects = await self._request(DOMAIN_OBJECTS)
123
+ match self._target_smile:
124
+ case "smile_v2":
125
+ self._modules = await self._request(MODULES)
126
+ case self._target_smile if self._target_smile in REQUIRE_APPLIANCES:
127
+ self._appliances = await self._request(APPLIANCES)
128
+
129
+ self._update_gw_entities()
130
+ # Detect failed data-retrieval
131
+ _ = self.gw_entities[self.gateway_id]["location"]
132
+ except KeyError as err: # pragma: no cover
133
+ raise DataMissingError("No legacy Plugwise data received") from err
134
+
135
+ self._first_update = False
135
136
  self._previous_day_number = day_number
136
- return PlugwiseData(
137
- devices=self.gw_entities,
138
- gateway=self.gw_data,
139
- )
137
+ return self.gw_entities
140
138
 
141
- ########################################################################################################
142
- ### API Set and HA Service-related Functions ###
143
- ########################################################################################################
139
+ ########################################################################################################
140
+ ### API Set and HA Service-related Functions ###
141
+ ########################################################################################################
144
142
 
145
143
  async def delete_notification(self) -> None:
146
144
  """Set-function placeholder for legacy devices."""
@@ -181,12 +179,16 @@ class SmileLegacyAPI(SmileLegacyData):
181
179
  async def set_regulation_mode(self, mode: str) -> None:
182
180
  """Set-function placeholder for legacy devices."""
183
181
 
184
- async def set_select(self, key: str, loc_id: str, option: str, state: str | None) -> None:
182
+ async def set_select(
183
+ self, key: str, loc_id: str, option: str, state: str | None
184
+ ) -> None:
185
185
  """Set the thermostat schedule option."""
186
186
  # schedule name corresponds to select option
187
187
  await self.set_schedule_state("dummy", state, option)
188
188
 
189
- async def set_schedule_state(self, _: str, state: str | None, name: str | None) -> None:
189
+ async def set_schedule_state(
190
+ self, _: str, state: str | None, name: str | None
191
+ ) -> None:
190
192
  """Activate/deactivate the Schedule.
191
193
 
192
194
  Determined from - DOMAIN_OBJECTS.
@@ -205,7 +207,9 @@ class SmileLegacyAPI(SmileLegacyData):
205
207
  schedule_rule_id = rule.attrib["id"]
206
208
 
207
209
  if schedule_rule_id is None:
208
- raise PlugwiseError("Plugwise: no schedule with this name available.") # pragma: no cover
210
+ raise PlugwiseError(
211
+ "Plugwise: no schedule with this name available."
212
+ ) # pragma: no cover
209
213
 
210
214
  new_state = "false"
211
215
  if state == "on":
@@ -290,6 +294,6 @@ class SmileLegacyAPI(SmileLegacyData):
290
294
  method: str = kwargs["method"]
291
295
  data: str | None = kwargs.get("data")
292
296
  try:
293
- await self.request(uri, method=method, data=data)
297
+ await self._request(uri, method=method, data=data)
294
298
  except ConnectionFailedError as exc:
295
299
  raise ConnectionFailedError from exc