plugwise 0.36.2__py3-none-any.whl → 0.37.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/__init__.py CHANGED
@@ -4,333 +4,39 @@ Plugwise backend module for Home Assistant Core.
4
4
  """
5
5
  from __future__ import annotations
6
6
 
7
- import datetime as dt
8
-
9
- import aiohttp
10
- from defusedxml import ElementTree as etree
11
-
12
- # Dict as class
13
- from munch import Munch
14
-
15
- # Version detection
16
- import semver
17
-
18
- from .constants import (
19
- ADAM,
20
- ANNA,
21
- APPLIANCES,
7
+ from plugwise.constants import (
22
8
  DEFAULT_PORT,
23
9
  DEFAULT_TIMEOUT,
24
10
  DEFAULT_USERNAME,
25
11
  DOMAIN_OBJECTS,
26
- LOCATIONS,
27
12
  LOGGER,
28
- MAX_SETPOINT,
29
- MIN_SETPOINT,
30
13
  MODULES,
31
14
  NONE,
32
- NOTIFICATIONS,
33
- OFF,
34
- REQUIRE_APPLIANCES,
35
- RULES,
36
15
  SMILES,
37
16
  STATUS,
38
- SWITCH_GROUP_TYPES,
39
17
  SYSTEM,
40
- ZONE_THERMOSTATS,
41
- ActuatorData,
42
- DeviceData,
43
- GatewayData,
44
18
  PlugwiseData,
19
+ ThermoLoc,
45
20
  )
46
- from .exceptions import (
21
+ from plugwise.exceptions import (
47
22
  InvalidSetupError,
48
23
  PlugwiseError,
49
24
  ResponseError,
50
25
  UnsupportedDeviceError,
51
26
  )
52
- from .helper import SmileComm, SmileHelper
53
-
54
-
55
- def remove_empty_platform_dicts(data: DeviceData) -> None:
56
- """Helper-function for removing any empty platform dicts."""
57
- if not data["binary_sensors"]:
58
- data.pop("binary_sensors")
59
- if not data["sensors"]:
60
- data.pop("sensors")
61
- if not data["switches"]:
62
- data.pop("switches")
63
-
64
-
65
- class SmileData(SmileHelper):
66
- """The Plugwise Smile main class."""
67
-
68
- def _update_gw_devices(self) -> None:
69
- """Helper-function for _all_device_data() and async_update().
70
-
71
- Collect data for each device and add to self.gw_devices.
72
- """
73
- for device_id, device in self.gw_devices.items():
74
- data = self._get_device_data(device_id)
75
- self._add_or_update_notifications(device_id, device, data)
76
- device.update(data)
77
- self._update_for_cooling(device)
78
- remove_empty_platform_dicts(device)
79
-
80
- def _add_or_update_notifications(
81
- self, device_id: str, device: DeviceData, data: DeviceData
82
- ) -> None:
83
- """Helper-function adding or updating the Plugwise notifications."""
84
- if (
85
- device_id == self.gateway_id
86
- and (
87
- self._is_thermostat
88
- or (self.smile_type == "power" and not self._smile_legacy)
89
- )
90
- ) or (
91
- "binary_sensors" in device
92
- and "plugwise_notification" in device["binary_sensors"]
93
- ):
94
- data["binary_sensors"]["plugwise_notification"] = bool(self._notifications)
95
- self._count += 1
96
-
97
- def _update_for_cooling(self, device: DeviceData) -> None:
98
- """Helper-function for adding/updating various cooling-related values."""
99
- # For Anna and heating + cooling, replace setpoint with setpoint_high/_low
100
- if (
101
- self.smile(ANNA)
102
- and self._cooling_present
103
- and device["dev_class"] == "thermostat"
104
- ):
105
- thermostat = device["thermostat"]
106
- sensors = device["sensors"]
107
- temp_dict: ActuatorData = {
108
- "setpoint_low": thermostat["setpoint"],
109
- "setpoint_high": MAX_SETPOINT,
110
- }
111
- if self._cooling_enabled:
112
- temp_dict = {
113
- "setpoint_low": MIN_SETPOINT,
114
- "setpoint_high": thermostat["setpoint"],
115
- }
116
- thermostat.pop("setpoint")
117
- temp_dict.update(thermostat)
118
- device["thermostat"] = temp_dict
119
- if "setpoint" in sensors:
120
- sensors.pop("setpoint")
121
- sensors["setpoint_low"] = temp_dict["setpoint_low"]
122
- sensors["setpoint_high"] = temp_dict["setpoint_high"]
123
- self._count += 2
124
-
125
- def get_all_devices(self) -> None:
126
- """Determine the evices present from the obtained XML-data.
127
-
128
- Run this functions once to gather the initial device configuration,
129
- then regularly run async_update() to refresh the device data.
130
- """
131
- # Gather all the devices and their initial data
132
- self._all_appliances()
133
- if self._is_thermostat:
134
- self._scan_thermostats()
135
- # Collect a list of thermostats with offset-capability
136
- self.therms_with_offset_func = (
137
- self._get_appliances_with_offset_functionality()
138
- )
139
-
140
- # Collect and add switching- and/or pump-group devices
141
- if group_data := self._get_group_switches():
142
- self.gw_devices.update(group_data)
143
-
144
- # Collect the remaining data for all devices
145
- self._all_device_data()
146
-
147
- def _all_device_data(self) -> None:
148
- """Helper-function for get_all_devices().
149
-
150
- Collect data for each device and add to self.gw_data and self.gw_devices.
151
- """
152
- self._update_gw_devices()
153
- self.device_items = self._count
154
- self.device_list = []
155
- for device in self.gw_devices:
156
- self.device_list.append(device)
157
-
158
- self.gw_data.update(
159
- {
160
- "gateway_id": self.gateway_id,
161
- "item_count": self._count,
162
- "notifications": self._notifications,
163
- "smile_name": self.smile_name,
164
- }
165
- )
166
- if self._is_thermostat:
167
- self.gw_data.update(
168
- {"heater_id": self._heater_id, "cooling_present": self._cooling_present}
169
- )
170
-
171
- def _device_data_switching_group(
172
- self, device: DeviceData, data: DeviceData
173
- ) -> None:
174
- """Helper-function for _get_device_data().
175
-
176
- Determine switching group device data.
177
- """
178
- if device["dev_class"] in SWITCH_GROUP_TYPES:
179
- counter = 0
180
- for member in device["members"]:
181
- if self.gw_devices[member]["switches"].get("relay"):
182
- counter += 1
183
- data["switches"]["relay"] = counter != 0
184
- self._count += 1
185
-
186
- def _device_data_adam(self, device: DeviceData, data: DeviceData) -> None:
187
- """Helper-function for _get_device_data().
188
-
189
- Determine Adam heating-status for on-off heating via valves,
190
- available regulations_modes and thermostat control_states.
191
- """
192
- if self.smile(ADAM):
193
- # Indicate heating_state based on valves being open in case of city-provided heating
194
- if (
195
- device["dev_class"] == "heater_central"
196
- and self._on_off_device
197
- and isinstance(self._heating_valves(), int)
198
- ):
199
- data["binary_sensors"]["heating_state"] = self._heating_valves() != 0
200
-
201
- # Show the allowed regulation modes and gateway_modes
202
- if device["dev_class"] == "gateway":
203
- if self._reg_allowed_modes:
204
- data["regulation_modes"] = self._reg_allowed_modes
205
- self._count += 1
206
- if self._gw_allowed_modes:
207
- data["gateway_modes"] = self._gw_allowed_modes
208
- self._count += 1
209
-
210
- # Control_state, only for Adam master thermostats
211
- if device["dev_class"] in ZONE_THERMOSTATS:
212
- loc_id = device["location"]
213
- if ctrl_state := self._control_state(loc_id):
214
- data["control_state"] = ctrl_state
215
- self._count += 1
216
-
217
- def _device_data_climate(self, device: DeviceData, data: DeviceData) -> None:
218
- """Helper-function for _get_device_data().
219
-
220
- Determine climate-control device data.
221
- """
222
- loc_id = device["location"]
223
-
224
- # Presets
225
- data["preset_modes"] = None
226
- data["active_preset"] = None
227
- self._count += 2
228
- if presets := self._presets(loc_id):
229
- data["preset_modes"] = list(presets)
230
- data["active_preset"] = self._preset(loc_id)
231
-
232
- # Schedule
233
- avail_schedules, sel_schedule = self._schedules(loc_id)
234
- data["available_schedules"] = avail_schedules
235
- data["select_schedule"] = sel_schedule
236
- self._count += 2
237
-
238
- # Operation modes: auto, heat, heat_cool, cool and off
239
- data["mode"] = "auto"
240
- self._count += 1
241
- if sel_schedule == NONE:
242
- data["mode"] = "heat"
243
- if self._cooling_present:
244
- data["mode"] = "cool" if self.check_reg_mode("cooling") else "heat_cool"
245
-
246
- if self.check_reg_mode("off"):
247
- data["mode"] = "off"
248
-
249
- if NONE not in avail_schedules:
250
- self._get_schedule_states_with_off(
251
- loc_id, avail_schedules, sel_schedule, data
252
- )
253
-
254
- def check_reg_mode(self, mode: str) -> bool:
255
- """Helper-function for device_data_climate()."""
256
- gateway = self.gw_devices[self.gateway_id]
257
- return (
258
- "regulation_modes" in gateway and gateway["select_regulation_mode"] == mode
259
- )
27
+ from plugwise.helper import SmileComm
28
+ from plugwise.legacy.smile import SmileLegacyAPI
29
+ from plugwise.smile import SmileAPI
260
30
 
261
- def _get_schedule_states_with_off(
262
- self, location: str, schedules: list[str], selected: str, data: DeviceData
263
- ) -> None:
264
- """Collect schedules with states for each thermostat.
265
-
266
- Also, replace NONE by OFF when none of the schedules are active,
267
- only for non-legacy thermostats.
268
- """
269
- loc_schedule_states: dict[str, str] = {}
270
- for schedule in schedules:
271
- loc_schedule_states[schedule] = "off"
272
- if schedule == selected and data["mode"] == "auto":
273
- loc_schedule_states[schedule] = "on"
274
- self._schedule_old_states[location] = loc_schedule_states
275
-
276
- all_off = True
277
- if not self._smile_legacy:
278
- for state in self._schedule_old_states[location].values():
279
- if state == "on":
280
- all_off = False
281
- if all_off:
282
- data["select_schedule"] = OFF
283
-
284
- def _check_availability(
285
- self, device: DeviceData, dev_class: str, data: DeviceData, message: str
286
- ) -> None:
287
- """Helper-function for _get_device_data().
288
-
289
- Provide availability status for the wired-commected devices.
290
- """
291
- if device["dev_class"] == dev_class:
292
- data["available"] = True
293
- self._count += 1
294
- for item in self._notifications.values():
295
- for msg in item.values():
296
- if message in msg:
297
- data["available"] = False
298
-
299
- def _get_device_data(self, dev_id: str) -> DeviceData:
300
- """Helper-function for _all_device_data() and async_update().
301
-
302
- Provide device-data, based on Location ID (= dev_id), from APPLIANCES.
303
- """
304
- device = self.gw_devices[dev_id]
305
- data = self._get_measurement_data(dev_id)
306
-
307
- # Check availability of non-legacy wired-connected devices
308
- if not self._smile_legacy:
309
- # Smartmeter
310
- self._check_availability(
311
- device, "smartmeter", data, "P1 does not seem to be connected"
312
- )
313
- # OpenTherm device
314
- if device["name"] != "OnOff":
315
- self._check_availability(
316
- device, "heater_central", data, "no OpenTherm communication"
317
- )
318
-
319
- # Switching groups data
320
- self._device_data_switching_group(device, data)
321
- # Adam data
322
- self._device_data_adam(device, data)
323
- # Skip obtaining data for non master-thermostats
324
- if device["dev_class"] not in ZONE_THERMOSTATS:
325
- return data
326
-
327
- # Thermostat data (presets, temperatures etc)
328
- self._device_data_climate(device, data)
31
+ import aiohttp
32
+ from defusedxml import ElementTree as etree
329
33
 
330
- return data
34
+ # Dict as class
35
+ # Version detection
36
+ import semver
331
37
 
332
38
 
333
- class Smile(SmileComm, SmileData):
39
+ class Smile(SmileComm):
334
40
  """The Plugwise SmileConnect class."""
335
41
 
336
42
  # pylint: disable=too-many-instance-attributes, too-many-public-methods
@@ -339,30 +45,56 @@ class Smile(SmileComm, SmileData):
339
45
  self,
340
46
  host: str,
341
47
  password: str,
48
+ websession: aiohttp.ClientSession,
342
49
  username: str = DEFAULT_USERNAME,
343
50
  port: int = DEFAULT_PORT,
344
51
  timeout: float = DEFAULT_TIMEOUT,
345
- websession: aiohttp.ClientSession | None = None,
52
+
346
53
  ) -> None:
347
54
  """Set the constructor for this class."""
348
55
  super().__init__(
349
56
  host,
350
57
  password,
58
+ websession,
351
59
  username,
352
60
  port,
353
61
  timeout,
354
- websession,
355
62
  )
356
- SmileData.__init__(self)
357
63
 
358
- self.smile_hostname: str | None = None
359
- self._previous_day_number: str = "0"
360
- self._target_smile: str | None = None
64
+ self._host = host
65
+ self._passwd = password
66
+ self._websession = websession
67
+ self._user = username
68
+ self._port = port
69
+ self._timeout = timeout
70
+
71
+ self._cooling_present = False
72
+ self._elga = False
73
+ self._is_thermostat = False
74
+ self._last_active: dict[str, str | None] = {}
75
+ self._on_off_device = False
76
+ self._opentherm_device = False
77
+ self._schedule_old_states: dict[str, dict[str, str]] = {}
78
+ self._smile_api: SmileAPI | SmileLegacyAPI
79
+ self._stretch_v2 = False
80
+ self._target_smile: str = NONE
81
+ self.gateway_id: str = NONE
82
+ self.loc_data: dict[str, ThermoLoc] = {}
83
+ self.smile_fw_version: str | None
84
+ self.smile_hostname: str
85
+ self.smile_hw_version: str | None = None
86
+ self.smile_legacy = False
87
+ self.smile_mac_address: str | None
88
+ self.smile_model: str
89
+ self.smile_name: str
90
+ self.smile_type: str
91
+ self.smile_version: tuple[str, semver.version.Version]
92
+ self.smile_zigbee_mac_address: str | None = None
361
93
 
362
94
  async def connect(self) -> bool:
363
95
  """Connect to Plugwise device and determine its name, type and version."""
364
96
  result = await self._request(DOMAIN_OBJECTS)
365
- # Work-around for Stretch fv 2.7.18
97
+ # Work-around for Stretch fw 2.7.18
366
98
  if not (vendor_names := result.findall("./module/vendor_name")):
367
99
  result = await self._request(MODULES)
368
100
  vendor_names = result.findall("./module/vendor_name")
@@ -395,58 +127,62 @@ class Smile(SmileComm, SmileData):
395
127
  # Determine smile specifics
396
128
  await self._smile_detect(result, dsmrmain)
397
129
 
130
+ self._smile_api = SmileAPI(
131
+ self._host,
132
+ self._passwd,
133
+ self._websession,
134
+ self._cooling_present,
135
+ self._elga,
136
+ self._is_thermostat,
137
+ self._last_active,
138
+ self._on_off_device,
139
+ self._opentherm_device,
140
+ self._schedule_old_states,
141
+ self.gateway_id,
142
+ self.loc_data,
143
+ self.smile_fw_version,
144
+ self.smile_hostname,
145
+ self.smile_hw_version,
146
+ self.smile_legacy,
147
+ self.smile_mac_address,
148
+ self.smile_model,
149
+ self.smile_name,
150
+ self.smile_type,
151
+ self.smile_version,
152
+ self._user,
153
+ self._port,
154
+ self._timeout,
155
+ )
156
+ if self.smile_legacy:
157
+ self._smile_api = SmileLegacyAPI(
158
+ self._host,
159
+ self._passwd,
160
+ self._websession,
161
+ self._is_thermostat,
162
+ self._on_off_device,
163
+ self._opentherm_device,
164
+ self._stretch_v2,
165
+ self._target_smile,
166
+ self.loc_data,
167
+ self.smile_fw_version,
168
+ self.smile_hostname,
169
+ self.smile_hw_version,
170
+ self.smile_mac_address,
171
+ self.smile_model,
172
+ self.smile_name,
173
+ self.smile_type,
174
+ self.smile_version,
175
+ self.smile_zigbee_mac_address,
176
+ self._user,
177
+ self._port,
178
+ self._timeout,
179
+ )
180
+
398
181
  # Update all endpoints on first connect
399
- await self._full_update_device()
182
+ await self._smile_api.full_update_device()
400
183
 
401
184
  return True
402
185
 
403
- async def _smile_detect_legacy(
404
- self, result: etree, dsmrmain: etree, model: str
405
- ) -> str:
406
- """Helper-function for _smile_detect()."""
407
- return_model = model
408
- # Stretch: find the MAC of the zigbee master_controller (= Stick)
409
- if (network := result.find("./module/protocols/master_controller")) is not None:
410
- self.smile_zigbee_mac_address = network.find("mac_address").text
411
- # Find the active MAC in case there is an orphaned Stick
412
- if zb_networks := result.findall("./network"):
413
- for zb_network in zb_networks:
414
- if zb_network.find("./nodes/network_router") is not None:
415
- network = zb_network.find("./master_controller")
416
- self.smile_zigbee_mac_address = network.find("mac_address").text
417
-
418
- # Legacy Anna or Stretch:
419
- if (
420
- result.find('./appliance[type="thermostat"]') is not None
421
- or network is not None
422
- ):
423
- self._system = await self._request(SYSTEM)
424
- self.smile_fw_version = self._system.find("./gateway/firmware").text
425
- return_model = self._system.find("./gateway/product").text
426
- self.smile_hostname = self._system.find("./gateway/hostname").text
427
- # If wlan0 contains data it's active, so eth0 should be checked last
428
- for network in ("wlan0", "eth0"):
429
- locator = f"./{network}/mac"
430
- if (net_locator := self._system.find(locator)) is not None:
431
- self.smile_mac_address = net_locator.text
432
- # P1 legacy:
433
- elif dsmrmain is not None:
434
- self._status = await self._request(STATUS)
435
- self.smile_fw_version = self._status.find("./system/version").text
436
- return_model = self._status.find("./system/product").text
437
- self.smile_hostname = self._status.find("./network/hostname").text
438
- self.smile_mac_address = self._status.find("./network/mac_address").text
439
- else: # pragma: no cover
440
- # No cornercase, just end of the line
441
- LOGGER.error(
442
- "Connected but no gateway device information found, please create"
443
- " an issue on http://github.com/plugwise/python-plugwise"
444
- )
445
- raise ResponseError
446
-
447
- self._smile_legacy = True
448
- return return_model
449
-
450
186
  async def _smile_detect(self, result: etree, dsmrmain: etree) -> None:
451
187
  """Helper-function for connect().
452
188
 
@@ -496,7 +232,6 @@ class Smile(SmileComm, SmileData):
496
232
 
497
233
  if self.smile_type == "stretch":
498
234
  self._stretch_v2 = self.smile_version[1].major == 2
499
- self._stretch_v3 = self.smile_version[1].major == 3
500
235
 
501
236
  if self.smile_type == "thermostat":
502
237
  self._is_thermostat = True
@@ -518,119 +253,70 @@ class Smile(SmileComm, SmileData):
518
253
  if result.find(locator_2) is not None:
519
254
  self._elga = True
520
255
 
521
- async def _full_update_device(self) -> None:
522
- """Perform a first fetch of all XML data, needed for initialization."""
523
- self._domain_objects = await self._request(DOMAIN_OBJECTS)
524
- self._get_plugwise_notifications()
525
- self._locations = await self._request(LOCATIONS)
526
- self._modules = await self._request(MODULES)
527
- # P1 legacy has no appliances
528
- if not (self.smile_type == "power" and self._smile_legacy):
529
- self._appliances = await self._request(APPLIANCES)
530
-
531
- def _get_plugwise_notifications(self) -> None:
532
- """Collect the Plugwise notifications."""
533
- self._notifications = {}
534
- for notification in self._domain_objects.findall("./notification"):
535
- try:
536
- msg_id = notification.attrib["id"]
537
- msg_type = notification.find("type").text
538
- msg = notification.find("message").text
539
- self._notifications.update({msg_id: {msg_type: msg}})
540
- LOGGER.debug("Plugwise notifications: %s", self._notifications)
541
- except AttributeError: # pragma: no cover
542
- LOGGER.debug(
543
- "Plugwise notification present but unable to process, manually investigate: %s",
544
- f"{self._endpoint}{DOMAIN_OBJECTS}",
545
- )
256
+ async def _smile_detect_legacy(
257
+ self, result: etree, dsmrmain: etree, model: str
258
+ ) -> str:
259
+ """Helper-function for _smile_detect()."""
260
+ return_model = model
261
+ # Stretch: find the MAC of the zigbee master_controller (= Stick)
262
+ if (network := result.find("./module/protocols/master_controller")) is not None:
263
+ self.smile_zigbee_mac_address = network.find("mac_address").text
264
+ # Find the active MAC in case there is an orphaned Stick
265
+ if zb_networks := result.findall("./network"):
266
+ for zb_network in zb_networks:
267
+ if zb_network.find("./nodes/network_router") is not None:
268
+ network = zb_network.find("./master_controller")
269
+ self.smile_zigbee_mac_address = network.find("mac_address").text
546
270
 
547
- async def async_update(self) -> PlugwiseData:
548
- """Perform an incremental update for updating the various device states."""
549
- # Perform a full update at day-change
550
- day_number = dt.datetime.now().strftime("%w")
271
+ # Legacy Anna or Stretch:
551
272
  if (
552
- day_number # pylint: disable=consider-using-assignment-expr
553
- != self._previous_day_number
273
+ result.find('./appliance[type="thermostat"]') is not None
274
+ or network is not None
554
275
  ):
555
- LOGGER.debug(
556
- "Performing daily full-update, reload the Plugwise integration when a single entity becomes unavailable."
276
+ system = await self._request(SYSTEM)
277
+ self.smile_fw_version = system.find("./gateway/firmware").text
278
+ return_model = system.find("./gateway/product").text
279
+ self.smile_hostname = system.find("./gateway/hostname").text
280
+ # If wlan0 contains data it's active, so eth0 should be checked last
281
+ for network in ("wlan0", "eth0"):
282
+ locator = f"./{network}/mac"
283
+ if (net_locator := system.find(locator)) is not None:
284
+ self.smile_mac_address = net_locator.text
285
+ # P1 legacy:
286
+ elif dsmrmain is not None:
287
+ status = await self._request(STATUS)
288
+ self.smile_fw_version = status.find("./system/version").text
289
+ return_model = status.find("./system/product").text
290
+ self.smile_hostname = status.find("./network/hostname").text
291
+ self.smile_mac_address = status.find("./network/mac_address").text
292
+ else: # pragma: no cover
293
+ # No cornercase, just end of the line
294
+ LOGGER.error(
295
+ "Connected but no gateway device information found, please create"
296
+ " an issue on http://github.com/plugwise/python-plugwise"
557
297
  )
558
- self.gw_data: GatewayData = {}
559
- self.gw_devices: dict[str, DeviceData] = {}
560
- await self._full_update_device()
561
- self.get_all_devices()
562
- # Otherwise perform an incremental update
563
- else:
564
- self._domain_objects = await self._request(DOMAIN_OBJECTS)
565
- self._get_plugwise_notifications()
566
- match self._target_smile:
567
- case "smile_v2":
568
- self._modules = await self._request(MODULES)
569
- case "smile_v3" | "smile_v4":
570
- self._locations = await self._request(LOCATIONS)
571
- case "smile_open_therm_v2" | "smile_open_therm_v3":
572
- self._appliances = await self._request(APPLIANCES)
573
- self._modules = await self._request(MODULES)
574
- case self._target_smile if self._target_smile in REQUIRE_APPLIANCES:
575
- self._appliances = await self._request(APPLIANCES)
576
-
577
- self._update_gw_devices()
578
- self.gw_data["notifications"] = self._notifications
579
-
580
- self._previous_day_number = day_number
581
- return PlugwiseData(self.gw_data, self.gw_devices)
582
-
583
- async def _set_schedule_state_legacy(
584
- self, loc_id: str, name: str, status: str
585
- ) -> None:
586
- """Helper-function for set_schedule_state()."""
587
- schedule_rule_id: str | None = None
588
- for rule in self._domain_objects.findall("rule"):
589
- if rule.find("name").text == name:
590
- schedule_rule_id = rule.attrib["id"]
591
-
592
- if schedule_rule_id is None:
593
- raise PlugwiseError("Plugwise: no schedule with this name available.")
594
-
595
- new_state = "false"
596
- if status == "on":
597
- new_state = "true"
598
- # If no state change is requested, do nothing
599
- if new_state == self._schedule_old_states[loc_id][name]:
600
- return
601
-
602
- locator = f'.//*[@id="{schedule_rule_id}"]/template'
603
- for rule in self._domain_objects.findall(locator):
604
- template_id = rule.attrib["id"]
605
-
606
- uri = f"{RULES};id={schedule_rule_id}"
607
- data = (
608
- "<rules><rule"
609
- f' id="{schedule_rule_id}"><name><![CDATA[{name}]]></name><template'
610
- f' id="{template_id}" /><active>{new_state}</active></rule></rules>'
611
- )
298
+ raise ResponseError
612
299
 
613
- await self._request(uri, method="put", data=data)
614
- self._schedule_old_states[loc_id][name] = new_state
300
+ self.smile_legacy = True
301
+ return return_model
615
302
 
616
- def determine_contexts(
617
- self, loc_id: str, name: str, state: str, sched_id: str
618
- ) -> etree:
619
- """Helper-function for set_schedule_state()."""
620
- locator = f'.//*[@id="{sched_id}"]/contexts'
621
- contexts = self._domain_objects.find(locator)
622
- locator = f'.//*[@id="{loc_id}"].../...'
623
- if (subject := contexts.find(locator)) is None:
624
- subject = f'<context><zone><location id="{loc_id}" /></zone></context>'
625
- subject = etree.fromstring(subject)
303
+ async def full_update_device(self) -> None:
304
+ """Perform a first fetch of all XML data, needed for initialization."""
305
+ await self._smile_api.full_update_device()
626
306
 
627
- if state == "off":
628
- self._last_active[loc_id] = name
629
- contexts.remove(subject)
630
- if state == "on":
631
- contexts.append(subject)
307
+ def get_all_devices(self) -> None:
308
+ """Determine the devices present from the obtained XML-data."""
309
+ self._smile_api.get_all_devices()
632
310
 
633
- return etree.tostring(contexts, encoding="unicode").rstrip()
311
+ async def async_update(self) -> PlugwiseData:
312
+ """Perform an incremental update for updating the various device states."""
313
+ data: PlugwiseData = await self._smile_api.async_update()
314
+ self.gateway_id = data.gateway["gateway_id"]
315
+ return data
316
+
317
+ ########################################################################################################
318
+ ### API Set and HA Service-related Functions ###
319
+ ########################################################################################################
634
320
 
635
321
  async def set_schedule_state(
636
322
  self,
@@ -638,280 +324,68 @@ class Smile(SmileComm, SmileData):
638
324
  new_state: str,
639
325
  name: str | None = None,
640
326
  ) -> None:
641
- """Activate/deactivate the Schedule, with the given name, on the relevant Thermostat.
642
-
643
- Determined from - DOMAIN_OBJECTS.
644
- Used in HA Core to set the hvac_mode: in practice switch between schedule on - off.
645
- """
646
- # Input checking
647
- if new_state not in ["on", "off"]:
648
- raise PlugwiseError("Plugwise: invalid schedule state.")
649
-
650
- # Translate selection of Off-schedule-option to disabling the active schedule
651
- if name == OFF:
652
- new_state = "off"
653
-
654
- # Handle no schedule-name / Off-schedule provided
655
- if name is None or name == OFF:
656
- if schedule_name := self._last_active[loc_id]:
657
- name = schedule_name
658
- else:
659
- return
660
-
661
- assert isinstance(name, str)
662
- if self._smile_legacy:
663
- await self._set_schedule_state_legacy(loc_id, name, new_state)
664
- return
665
-
666
- schedule_rule = self._rule_ids_by_name(name, loc_id)
667
- # Raise an error when the schedule name does not exist
668
- if not schedule_rule or schedule_rule is None:
669
- raise PlugwiseError("Plugwise: no schedule with this name available.")
670
-
671
- # If no state change is requested, do nothing
672
- if new_state == self._schedule_old_states[loc_id][name]:
673
- return
674
-
675
- schedule_rule_id: str = next(iter(schedule_rule))
676
- template = (
677
- '<template tag="zone_preset_based_on_time_and_presence_with_override" />'
678
- )
679
- if self.smile(ANNA):
680
- locator = f'.//*[@id="{schedule_rule_id}"]/template'
681
- template_id = self._domain_objects.find(locator).attrib["id"]
682
- template = f'<template id="{template_id}" />'
683
-
684
- contexts = self.determine_contexts(loc_id, name, new_state, schedule_rule_id)
685
- uri = f"{RULES};id={schedule_rule_id}"
686
- data = (
687
- f'<rules><rule id="{schedule_rule_id}"><name><![CDATA[{name}]]></name>'
688
- f"{template}{contexts}</rule></rules>"
689
- )
690
-
691
- await self._request(uri, method="put", data=data)
692
- self._schedule_old_states[loc_id][name] = new_state
693
-
694
- async def _set_preset_legacy(self, preset: str) -> None:
695
- """Set the given Preset on the relevant Thermostat - from DOMAIN_OBJECTS."""
696
- locator = f'rule/directives/when/then[@icon="{preset}"].../.../...'
697
- rule = self._domain_objects.find(locator)
698
- data = f'<rules><rule id="{rule.attrib["id"]}"><active>true</active></rule></rules>'
699
-
700
- await self._request(RULES, method="put", data=data)
327
+ """Activate/deactivate the Schedule, with the given name, on the relevant Thermostat."""
328
+ await self._smile_api.set_schedule_state(loc_id, new_state, name)
701
329
 
702
330
  async def set_preset(self, loc_id: str, preset: str) -> None:
703
- """Set the given Preset on the relevant Thermostat - from LOCATIONS."""
704
- if (presets := self._presets(loc_id)) is None:
705
- raise PlugwiseError("Plugwise: no presets available.") # pragma: no cover
706
- if preset not in list(presets):
707
- raise PlugwiseError("Plugwise: invalid preset.")
708
-
709
- if self._smile_legacy:
710
- await self._set_preset_legacy(preset)
711
- return
712
-
713
- current_location = self._locations.find(f'location[@id="{loc_id}"]')
714
- location_name = current_location.find("name").text
715
- location_type = current_location.find("type").text
716
-
717
- uri = f"{LOCATIONS};id={loc_id}"
718
- data = (
719
- "<locations><location"
720
- f' id="{loc_id}"><name>{location_name}</name><type>{location_type}'
721
- f"</type><preset>{preset}</preset></location></locations>"
722
- )
723
-
724
- await self._request(uri, method="put", data=data)
331
+ """Set the given Preset on the relevant Thermostat."""
332
+ await self._smile_api.set_preset(loc_id, preset)
725
333
 
726
334
  async def set_temperature(self, loc_id: str, items: dict[str, float]) -> None:
727
335
  """Set the given Temperature on the relevant Thermostat."""
728
- setpoint: float | None = None
729
-
730
- if "setpoint" in items:
731
- setpoint = items["setpoint"]
732
-
733
- if self.smile(ANNA) and self._cooling_present:
734
- if "setpoint_high" not in items:
735
- raise PlugwiseError(
736
- "Plugwise: failed setting temperature: no valid input provided"
737
- )
738
- tmp_setpoint_high = items["setpoint_high"]
739
- tmp_setpoint_low = items["setpoint_low"]
740
- if self._cooling_enabled: # in cooling mode
741
- setpoint = tmp_setpoint_high
742
- if tmp_setpoint_low != MIN_SETPOINT:
743
- raise PlugwiseError(
744
- "Plugwise: heating setpoint cannot be changed when in cooling mode"
745
- )
746
- else: # in heating mode
747
- setpoint = tmp_setpoint_low
748
- if tmp_setpoint_high != MAX_SETPOINT:
749
- raise PlugwiseError(
750
- "Plugwise: cooling setpoint cannot be changed when in heating mode"
751
- )
752
-
753
- if setpoint is None:
336
+ try:
337
+ await self._smile_api.set_temperature(loc_id, items)
338
+ except PlugwiseError as exc:
754
339
  raise PlugwiseError(
755
- "Plugwise: failed setting temperature: no valid input provided"
756
- ) # pragma: no cover"
757
-
758
- temperature = str(setpoint)
759
- uri = self._thermostat_uri(loc_id)
760
- data = (
761
- "<thermostat_functionality><setpoint>"
762
- f"{temperature}</setpoint></thermostat_functionality>"
763
- )
764
-
765
- await self._request(uri, method="put", data=data)
340
+ "Plugwise: failed setting temperature: no valid input provided"
341
+ ) from exc
766
342
 
767
343
  async def set_number_setpoint(self, key: str, _: str, temperature: float) -> None:
768
344
  """Set the max. Boiler or DHW setpoint on the Central Heating boiler."""
769
- temp = str(temperature)
770
- thermostat_id: str | None = None
771
- locator = f'appliance[@id="{self._heater_id}"]/actuator_functionalities/thermostat_functionality'
772
- if th_func_list := self._appliances.findall(locator):
773
- for th_func in th_func_list:
774
- if th_func.find("type").text == key:
775
- thermostat_id = th_func.attrib["id"]
776
-
777
- if thermostat_id is None:
778
- raise PlugwiseError(f"Plugwise: cannot change setpoint, {key} not found.")
779
-
780
- uri = f"{APPLIANCES};id={self._heater_id}/thermostat;id={thermostat_id}"
781
- data = f"<thermostat_functionality><setpoint>{temp}</setpoint></thermostat_functionality>"
782
- await self._request(uri, method="put", data=data)
345
+ try:
346
+ await self._smile_api.set_number_setpoint(key, temperature)
347
+ except PlugwiseError as exc:
348
+ raise PlugwiseError(f"Plugwise: cannot change setpoint, {key} not found.") from exc
783
349
 
784
350
  async def set_temperature_offset(self, _: str, dev_id: str, offset: float) -> None:
785
351
  """Set the Temperature offset for thermostats that support this feature."""
786
- if dev_id not in self.therms_with_offset_func:
352
+ try:
353
+ await self._smile_api.set_temperature_offset(dev_id, offset)
354
+ except PlugwiseError as exc:
787
355
  raise PlugwiseError(
788
356
  "Plugwise: this device does not have temperature-offset capability."
789
- )
790
-
791
- value = str(offset)
792
- uri = f"{APPLIANCES};id={dev_id}/offset;type=temperature_offset"
793
- data = f"<offset_functionality><offset>{value}</offset></offset_functionality>"
794
-
795
- await self._request(uri, method="put", data=data)
796
-
797
- async def _set_groupswitch_member_state(
798
- self, members: list[str], state: str, switch: Munch
799
- ) -> None:
800
- """Helper-function for set_switch_state().
801
-
802
- Set the given State of the relevant Switch within a group of members.
803
- """
804
- for member in members:
805
- locator = f'appliance[@id="{member}"]/{switch.actuator}/{switch.func_type}'
806
- switch_id = self._appliances.find(locator).attrib["id"]
807
- uri = f"{APPLIANCES};id={member}/{switch.device};id={switch_id}"
808
- if self._stretch_v2:
809
- uri = f"{APPLIANCES};id={member}/{switch.device}"
810
- data = f"<{switch.func_type}><{switch.func}>{state}</{switch.func}></{switch.func_type}>"
811
-
812
- await self._request(uri, method="put", data=data)
357
+ ) from exc
813
358
 
814
359
  async def set_switch_state(
815
360
  self, appl_id: str, members: list[str] | None, model: str, state: str
816
361
  ) -> None:
817
362
  """Set the given State of the relevant Switch."""
818
- switch = Munch()
819
- switch.actuator = "actuator_functionalities"
820
- switch.device = "relay"
821
- switch.func_type = "relay_functionality"
822
- switch.func = "state"
823
- if model == "dhw_cm_switch":
824
- switch.device = "toggle"
825
- switch.func_type = "toggle_functionality"
826
- switch.act_type = "domestic_hot_water_comfort_mode"
827
-
828
- if model == "cooling_ena_switch":
829
- switch.device = "toggle"
830
- switch.func_type = "toggle_functionality"
831
- switch.act_type = "cooling_enabled"
832
-
833
- if model == "lock":
834
- switch.func = "lock"
835
- state = "false" if state == "off" else "true"
836
-
837
- if self._stretch_v2:
838
- switch.actuator = "actuators"
839
- switch.func_type = "relay"
840
-
841
- if members is not None:
842
- return await self._set_groupswitch_member_state(members, state, switch)
843
-
844
- locator = f'appliance[@id="{appl_id}"]/{switch.actuator}/{switch.func_type}'
845
- found: list[etree] = self._appliances.findall(locator)
846
- for item in found:
847
- if (sw_type := item.find("type")) is not None:
848
- if sw_type.text == switch.act_type:
849
- switch_id = item.attrib["id"]
850
- else:
851
- switch_id = item.attrib["id"]
852
- break
853
-
854
- uri = f"{APPLIANCES};id={appl_id}/{switch.device};id={switch_id}"
855
- if self._stretch_v2:
856
- uri = f"{APPLIANCES};id={appl_id}/{switch.device}"
857
- data = f"<{switch.func_type}><{switch.func}>{state}</{switch.func}></{switch.func_type}>"
858
-
859
- if model == "relay":
860
- locator = (
861
- f'appliance[@id="{appl_id}"]/{switch.actuator}/{switch.func_type}/lock'
862
- )
863
- # Don't bother switching a relay when the corresponding lock-state is true
864
- if self._appliances.find(locator).text == "true":
865
- raise PlugwiseError("Plugwise: the locked Relay was not switched.")
866
-
867
- await self._request(uri, method="put", data=data)
363
+ try:
364
+ await self._smile_api.set_switch_state(appl_id, members, model, state)
365
+ except PlugwiseError as exc:
366
+ raise PlugwiseError("Plugwise: the locked Relay was not switched.") from exc
868
367
 
869
368
  async def set_gateway_mode(self, mode: str) -> None:
870
369
  """Set the gateway mode."""
871
- if mode not in self._gw_allowed_modes:
872
- raise PlugwiseError("Plugwise: invalid gateway mode.")
873
-
874
- time_1 = dt.datetime.now(dt.UTC)
875
- away_time = time_1.isoformat(timespec="milliseconds") + "Z"
876
- time_2 = str(dt.date.today() - dt.timedelta(1))
877
- vacation_time = time_2 + "T23:00:00.000Z"
878
- end_time = "2037-04-21T08:00:53.000Z"
879
- valid = ""
880
- if mode == "away":
881
- valid = (
882
- f"<valid_from>{away_time}</valid_from><valid_to>{end_time}</valid_to>"
883
- )
884
- if mode == "vacation":
885
- valid = f"<valid_from>{vacation_time}</valid_from><valid_to>{end_time}</valid_to>"
886
-
887
- uri = f"{APPLIANCES};type=gateway/gateway_mode_control"
888
- data = f"<gateway_mode_control_functionality><mode>{mode}</mode>{valid}</gateway_mode_control_functionality>"
889
-
890
- await self._request(uri, method="put", data=data)
370
+ try:
371
+ await self._smile_api.set_gateway_mode(mode)
372
+ except PlugwiseError as exc:
373
+ raise PlugwiseError("Plugwise: invalid gateway mode.") from exc
891
374
 
892
375
  async def set_regulation_mode(self, mode: str) -> None:
893
376
  """Set the heating regulation mode."""
894
- if mode not in self._reg_allowed_modes:
895
- raise PlugwiseError("Plugwise: invalid regulation mode.")
896
-
897
- uri = f"{APPLIANCES};type=gateway/regulation_mode_control"
898
- duration = ""
899
- if "bleeding" in mode:
900
- duration = "<duration>300</duration>"
901
- data = f"<regulation_mode_control_functionality>{duration}<mode>{mode}</mode></regulation_mode_control_functionality>"
902
-
903
- await self._request(uri, method="put", data=data)
377
+ try:
378
+ await self._smile_api.set_regulation_mode(mode)
379
+ except PlugwiseError as exc:
380
+ raise PlugwiseError("Plugwise: invalid regulation mode.") from exc
904
381
 
905
382
  async def set_dhw_mode(self, mode: str) -> None:
906
383
  """Set the domestic hot water heating regulation mode."""
907
- if mode not in self._dhw_allowed_modes:
908
- raise PlugwiseError("Plugwise: invalid dhw mode.")
909
-
910
- uri = f"{APPLIANCES};type=heater_central/domestic_hot_water_mode_control"
911
- data = f"<domestic_hot_water_mode_control_functionality><mode>{mode}</mode></domestic_hot_water_mode_control_functionality>"
912
-
913
- await self._request(uri, method="put", data=data)
384
+ try:
385
+ await self._smile_api.set_dhw_mode(mode)
386
+ except PlugwiseError as exc:
387
+ raise PlugwiseError("Plugwise: invalid dhw mode.") from exc
914
388
 
915
389
  async def delete_notification(self) -> None:
916
390
  """Delete the active Plugwise Notification."""
917
- await self._request(NOTIFICATIONS, method="delete")
391
+ await self._smile_api.delete_notification()