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 +187 -713
- plugwise/data.py +263 -0
- plugwise/helper.py +141 -381
- plugwise/legacy/data.py +124 -0
- plugwise/legacy/helper.py +775 -0
- plugwise/legacy/smile.py +272 -0
- plugwise/smile.py +426 -0
- plugwise/util.py +35 -1
- {plugwise-0.36.2.dist-info → plugwise-0.37.0.dist-info}/METADATA +2 -2
- plugwise-0.37.0.dist-info/RECORD +16 -0
- {plugwise-0.36.2.dist-info → plugwise-0.37.0.dist-info}/WHEEL +1 -1
- plugwise-0.36.2.dist-info/RECORD +0 -11
- {plugwise-0.36.2.dist-info → plugwise-0.37.0.dist-info}/LICENSE +0 -0
- {plugwise-0.36.2.dist-info → plugwise-0.37.0.dist-info}/top_level.txt +0 -0
plugwise/legacy/smile.py
ADDED
@@ -0,0 +1,272 @@
|
|
1
|
+
"""Use of this source code is governed by the MIT license found in the LICENSE file.
|
2
|
+
|
3
|
+
Plugwise backend module for Home Assistant Core - covering the legacy P1, Anna, and Stretch devices.
|
4
|
+
"""
|
5
|
+
from __future__ import annotations
|
6
|
+
|
7
|
+
import datetime as dt
|
8
|
+
|
9
|
+
# Version detection
|
10
|
+
from plugwise.constants import (
|
11
|
+
APPLIANCES,
|
12
|
+
DEFAULT_PORT,
|
13
|
+
DEFAULT_TIMEOUT,
|
14
|
+
DEFAULT_USERNAME,
|
15
|
+
DOMAIN_OBJECTS,
|
16
|
+
LOCATIONS,
|
17
|
+
LOGGER,
|
18
|
+
MODULES,
|
19
|
+
REQUIRE_APPLIANCES,
|
20
|
+
RULES,
|
21
|
+
DeviceData,
|
22
|
+
GatewayData,
|
23
|
+
PlugwiseData,
|
24
|
+
ThermoLoc,
|
25
|
+
)
|
26
|
+
from plugwise.exceptions import PlugwiseError
|
27
|
+
from plugwise.helper import SmileComm
|
28
|
+
from plugwise.legacy.data import SmileLegacyData
|
29
|
+
|
30
|
+
import aiohttp
|
31
|
+
from munch import Munch
|
32
|
+
import semver
|
33
|
+
|
34
|
+
|
35
|
+
class SmileLegacyAPI(SmileComm, SmileLegacyData):
|
36
|
+
"""The Plugwise SmileLegacyAPI class."""
|
37
|
+
|
38
|
+
# pylint: disable=too-many-instance-attributes, too-many-public-methods
|
39
|
+
|
40
|
+
def __init__(
|
41
|
+
self,
|
42
|
+
host: str,
|
43
|
+
password: str,
|
44
|
+
websession: aiohttp.ClientSession,
|
45
|
+
_is_thermostat: bool,
|
46
|
+
_on_off_device: bool,
|
47
|
+
_opentherm_device: bool,
|
48
|
+
_stretch_v2: bool,
|
49
|
+
_target_smile: str,
|
50
|
+
loc_data: dict[str, ThermoLoc],
|
51
|
+
smile_fw_version: str | None,
|
52
|
+
smile_hostname: str,
|
53
|
+
smile_hw_version: str | None,
|
54
|
+
smile_mac_address: str | None,
|
55
|
+
smile_model: str,
|
56
|
+
smile_name: str,
|
57
|
+
smile_type: str,
|
58
|
+
smile_version: tuple[str, semver.version.Version],
|
59
|
+
smile_zigbee_mac_address: str | None,
|
60
|
+
username: str = DEFAULT_USERNAME,
|
61
|
+
port: int = DEFAULT_PORT,
|
62
|
+
timeout: float = DEFAULT_TIMEOUT,
|
63
|
+
) -> None:
|
64
|
+
"""Set the constructor for this class."""
|
65
|
+
super().__init__(
|
66
|
+
host,
|
67
|
+
password,
|
68
|
+
websession,
|
69
|
+
username,
|
70
|
+
port,
|
71
|
+
timeout,
|
72
|
+
)
|
73
|
+
SmileLegacyData.__init__(self)
|
74
|
+
|
75
|
+
self._is_thermostat = _is_thermostat
|
76
|
+
self._on_off_device = _on_off_device
|
77
|
+
self._opentherm_device = _opentherm_device
|
78
|
+
self._stretch_v2 = _stretch_v2
|
79
|
+
self._target_smile = _target_smile
|
80
|
+
self.loc_data = loc_data
|
81
|
+
self.smile_fw_version = smile_fw_version
|
82
|
+
self.smile_hostname = smile_hostname
|
83
|
+
self.smile_hw_version = smile_hw_version
|
84
|
+
self.smile_mac_address = smile_mac_address
|
85
|
+
self.smile_model = smile_model
|
86
|
+
self.smile_name = smile_name
|
87
|
+
self.smile_type = smile_type
|
88
|
+
self.smile_version = smile_version
|
89
|
+
self.smile_zigbee_mac_address = smile_zigbee_mac_address
|
90
|
+
|
91
|
+
self._previous_day_number: str = "0"
|
92
|
+
|
93
|
+
async def full_update_device(self) -> None:
|
94
|
+
"""Perform a first fetch of all XML data, needed for initialization."""
|
95
|
+
self._domain_objects = await self._request(DOMAIN_OBJECTS)
|
96
|
+
self._locations = await self._request(LOCATIONS)
|
97
|
+
self._modules = await self._request(MODULES)
|
98
|
+
# P1 legacy has no appliances
|
99
|
+
if self.smile_type != "power":
|
100
|
+
self._appliances = await self._request(APPLIANCES)
|
101
|
+
|
102
|
+
def get_all_devices(self) -> None:
|
103
|
+
"""Determine the evices present from the obtained XML-data.
|
104
|
+
|
105
|
+
Run this functions once to gather the initial device configuration,
|
106
|
+
then regularly run async_update() to refresh the device data.
|
107
|
+
"""
|
108
|
+
# Gather all the devices and their initial data
|
109
|
+
self._all_appliances()
|
110
|
+
|
111
|
+
# Collect and add switching- and/or pump-group devices
|
112
|
+
if group_data := self._get_group_switches():
|
113
|
+
self.gw_devices.update(group_data)
|
114
|
+
|
115
|
+
# Collect the remaining data for all devices
|
116
|
+
self._all_device_data()
|
117
|
+
|
118
|
+
async def async_update(self) -> PlugwiseData:
|
119
|
+
"""Perform an incremental update for updating the various device states."""
|
120
|
+
# Perform a full update at day-change
|
121
|
+
day_number = dt.datetime.now().strftime("%w")
|
122
|
+
if (
|
123
|
+
day_number # pylint: disable=consider-using-assignment-expr
|
124
|
+
!= self._previous_day_number
|
125
|
+
):
|
126
|
+
LOGGER.info(
|
127
|
+
"Performing daily full-update, reload the Plugwise integration when a single entity becomes unavailable."
|
128
|
+
)
|
129
|
+
self.gw_data: GatewayData = {}
|
130
|
+
self.gw_devices: dict[str, DeviceData] = {}
|
131
|
+
await self.full_update_device()
|
132
|
+
self.get_all_devices()
|
133
|
+
# Otherwise perform an incremental update
|
134
|
+
else:
|
135
|
+
self._domain_objects = await self._request(DOMAIN_OBJECTS)
|
136
|
+
match self._target_smile:
|
137
|
+
case "smile_v2":
|
138
|
+
self._modules = await self._request(MODULES)
|
139
|
+
case self._target_smile if self._target_smile in REQUIRE_APPLIANCES:
|
140
|
+
self._appliances = await self._request(APPLIANCES)
|
141
|
+
|
142
|
+
self._update_gw_devices()
|
143
|
+
|
144
|
+
self._previous_day_number = day_number
|
145
|
+
return PlugwiseData(self.gw_data, self.gw_devices)
|
146
|
+
|
147
|
+
########################################################################################################
|
148
|
+
### API Set and HA Service-related Functions ###
|
149
|
+
########################################################################################################
|
150
|
+
|
151
|
+
async def set_schedule_state(self, _: str, state: str, __: str | None) -> None:
|
152
|
+
"""Activate/deactivate the Schedule.
|
153
|
+
|
154
|
+
Determined from - DOMAIN_OBJECTS.
|
155
|
+
Used in HA Core to set the hvac_mode: in practice switch between schedule on - off.
|
156
|
+
"""
|
157
|
+
if state not in ["on", "off"]:
|
158
|
+
raise PlugwiseError("Plugwise: invalid schedule state.")
|
159
|
+
|
160
|
+
name = "Thermostat schedule"
|
161
|
+
schedule_rule_id: str | None = None
|
162
|
+
for rule in self._domain_objects.findall("rule"):
|
163
|
+
if rule.find("name").text == name:
|
164
|
+
schedule_rule_id = rule.attrib["id"]
|
165
|
+
|
166
|
+
if schedule_rule_id is None:
|
167
|
+
raise PlugwiseError("Plugwise: no schedule with this name available.") # pragma: no cover
|
168
|
+
|
169
|
+
new_state = "false"
|
170
|
+
if state == "on":
|
171
|
+
new_state = "true"
|
172
|
+
|
173
|
+
locator = f'.//*[@id="{schedule_rule_id}"]/template'
|
174
|
+
for rule in self._domain_objects.findall(locator):
|
175
|
+
template_id = rule.attrib["id"]
|
176
|
+
|
177
|
+
uri = f"{RULES};id={schedule_rule_id}"
|
178
|
+
data = (
|
179
|
+
"<rules><rule"
|
180
|
+
f' id="{schedule_rule_id}"><name><![CDATA[{name}]]></name><template'
|
181
|
+
f' id="{template_id}" /><active>{new_state}</active></rule></rules>'
|
182
|
+
)
|
183
|
+
|
184
|
+
await self._request(uri, method="put", data=data)
|
185
|
+
|
186
|
+
async def set_preset(self, _: str, preset: str) -> None:
|
187
|
+
"""Set the given Preset on the relevant Thermostat - from DOMAIN_OBJECTS."""
|
188
|
+
if (presets := self._presets()) is None:
|
189
|
+
raise PlugwiseError("Plugwise: no presets available.") # pragma: no cover
|
190
|
+
if preset not in list(presets):
|
191
|
+
raise PlugwiseError("Plugwise: invalid preset.")
|
192
|
+
|
193
|
+
locator = f'rule/directives/when/then[@icon="{preset}"].../.../...'
|
194
|
+
rule = self._domain_objects.find(locator)
|
195
|
+
data = f'<rules><rule id="{rule.attrib["id"]}"><active>true</active></rule></rules>'
|
196
|
+
|
197
|
+
await self._request(RULES, method="put", data=data)
|
198
|
+
|
199
|
+
async def set_temperature(self, setpoint: str, _: dict[str, float]) -> None:
|
200
|
+
"""Set the given Temperature on the relevant Thermostat."""
|
201
|
+
if setpoint is None:
|
202
|
+
raise PlugwiseError(
|
203
|
+
"Plugwise: failed setting temperature: no valid input provided"
|
204
|
+
) # pragma: no cover"
|
205
|
+
|
206
|
+
temperature = str(setpoint)
|
207
|
+
uri = self._thermostat_uri()
|
208
|
+
data = (
|
209
|
+
"<thermostat_functionality><setpoint>"
|
210
|
+
f"{temperature}</setpoint></thermostat_functionality>"
|
211
|
+
)
|
212
|
+
|
213
|
+
await self._request(uri, method="put", data=data)
|
214
|
+
|
215
|
+
async def set_switch_state(
|
216
|
+
self, appl_id: str, members: list[str] | None, model: str, state: str
|
217
|
+
) -> None:
|
218
|
+
"""Set the given State of the relevant Switch."""
|
219
|
+
switch = Munch()
|
220
|
+
switch.actuator = "actuator_functionalities"
|
221
|
+
switch.func_type = "relay_functionality"
|
222
|
+
if self._stretch_v2:
|
223
|
+
switch.actuator = "actuators"
|
224
|
+
switch.func_type = "relay"
|
225
|
+
switch.func = "state"
|
226
|
+
|
227
|
+
if members is not None:
|
228
|
+
return await self._set_groupswitch_member_state(members, state, switch)
|
229
|
+
|
230
|
+
data = f"<{switch.func_type}><{switch.func}>{state}</{switch.func}></{switch.func_type}>"
|
231
|
+
uri = f"{APPLIANCES};id={appl_id}/{switch.func_type}"
|
232
|
+
|
233
|
+
if model == "relay":
|
234
|
+
locator = (
|
235
|
+
f'appliance[@id="{appl_id}"]/{switch.actuator}/{switch.func_type}/lock'
|
236
|
+
)
|
237
|
+
# Don't bother switching a relay when the corresponding lock-state is true
|
238
|
+
if self._appliances.find(locator).text == "true":
|
239
|
+
raise PlugwiseError("Plugwise: the locked Relay was not switched.")
|
240
|
+
|
241
|
+
await self._request(uri, method="put", data=data)
|
242
|
+
|
243
|
+
async def _set_groupswitch_member_state(
|
244
|
+
self, members: list[str], state: str, switch: Munch
|
245
|
+
) -> None:
|
246
|
+
"""Helper-function for set_switch_state().
|
247
|
+
|
248
|
+
Set the given State of the relevant Switch within a group of members.
|
249
|
+
"""
|
250
|
+
for member in members:
|
251
|
+
uri = f"{APPLIANCES};id={member}/{switch.func_type}"
|
252
|
+
data = f"<{switch.func_type}><{switch.func}>{state}</{switch.func}></{switch.func_type}>"
|
253
|
+
|
254
|
+
await self._request(uri, method="put", data=data)
|
255
|
+
|
256
|
+
async def set_number_setpoint(self, key: str, temperature: float) -> None:
|
257
|
+
"""Set-function placeholder for legacy devices."""
|
258
|
+
|
259
|
+
async def set_temperature_offset(self, dev_id: str, offset: float) -> None:
|
260
|
+
"""Set-function placeholder for legacy devices."""
|
261
|
+
|
262
|
+
async def set_gateway_mode(self, mode: str) -> None:
|
263
|
+
"""Set-function placeholder for legacy devices."""
|
264
|
+
|
265
|
+
async def set_regulation_mode(self, mode: str) -> None:
|
266
|
+
"""Set-function placeholder for legacy devices."""
|
267
|
+
|
268
|
+
async def set_dhw_mode(self, mode: str) -> None:
|
269
|
+
"""Set-function placeholder for legacy devices."""
|
270
|
+
|
271
|
+
async def delete_notification(self) -> None:
|
272
|
+
"""Set-function placeholder for legacy devices."""
|
plugwise/smile.py
ADDED
@@ -0,0 +1,426 @@
|
|
1
|
+
"""Use of this source code is governed by the MIT license found in the LICENSE file.
|
2
|
+
|
3
|
+
Plugwise backend module for Home Assistant Core.
|
4
|
+
"""
|
5
|
+
from __future__ import annotations
|
6
|
+
|
7
|
+
import datetime as dt
|
8
|
+
|
9
|
+
from plugwise.constants import (
|
10
|
+
ADAM,
|
11
|
+
ANNA,
|
12
|
+
APPLIANCES,
|
13
|
+
DEFAULT_PORT,
|
14
|
+
DEFAULT_TIMEOUT,
|
15
|
+
DEFAULT_USERNAME,
|
16
|
+
DOMAIN_OBJECTS,
|
17
|
+
LOCATIONS,
|
18
|
+
MAX_SETPOINT,
|
19
|
+
MIN_SETPOINT,
|
20
|
+
NOTIFICATIONS,
|
21
|
+
OFF,
|
22
|
+
RULES,
|
23
|
+
DeviceData,
|
24
|
+
GatewayData,
|
25
|
+
PlugwiseData,
|
26
|
+
ThermoLoc,
|
27
|
+
)
|
28
|
+
from plugwise.data import SmileData
|
29
|
+
from plugwise.exceptions import PlugwiseError
|
30
|
+
from plugwise.helper import SmileComm
|
31
|
+
|
32
|
+
import aiohttp
|
33
|
+
from defusedxml import ElementTree as etree
|
34
|
+
|
35
|
+
# Dict as class
|
36
|
+
from munch import Munch
|
37
|
+
|
38
|
+
# Version detection
|
39
|
+
import semver
|
40
|
+
|
41
|
+
|
42
|
+
class SmileAPI(SmileComm, SmileData):
|
43
|
+
"""The Plugwise SmileAPI helper class for actual Plugwise devices."""
|
44
|
+
|
45
|
+
# pylint: disable=too-many-instance-attributes, too-many-public-methods
|
46
|
+
|
47
|
+
def __init__(
|
48
|
+
self,
|
49
|
+
host: str,
|
50
|
+
password: str,
|
51
|
+
websession: aiohttp.ClientSession,
|
52
|
+
_cooling_present: bool,
|
53
|
+
_elga: bool,
|
54
|
+
_is_thermostat: bool,
|
55
|
+
_last_active: dict[str, str | None],
|
56
|
+
_on_off_device: bool,
|
57
|
+
_opentherm_device: bool,
|
58
|
+
_schedule_old_states: dict[str, dict[str, str]],
|
59
|
+
gateway_id: str,
|
60
|
+
loc_data: dict[str, ThermoLoc],
|
61
|
+
smile_fw_version: str | None,
|
62
|
+
smile_hostname: str | None,
|
63
|
+
smile_hw_version: str | None,
|
64
|
+
smile_legacy: bool,
|
65
|
+
smile_mac_address: str | None,
|
66
|
+
smile_model: str,
|
67
|
+
smile_name: str,
|
68
|
+
smile_type: str,
|
69
|
+
smile_version: tuple[str, semver.version.Version],
|
70
|
+
username: str = DEFAULT_USERNAME,
|
71
|
+
port: int = DEFAULT_PORT,
|
72
|
+
timeout: float = DEFAULT_TIMEOUT,
|
73
|
+
|
74
|
+
) -> None:
|
75
|
+
"""Set the constructor for this class."""
|
76
|
+
super().__init__(
|
77
|
+
host,
|
78
|
+
password,
|
79
|
+
websession,
|
80
|
+
username,
|
81
|
+
port,
|
82
|
+
timeout,
|
83
|
+
)
|
84
|
+
SmileData.__init__(self)
|
85
|
+
|
86
|
+
self._cooling_present = _cooling_present
|
87
|
+
self._elga = _elga
|
88
|
+
self._is_thermostat = _is_thermostat
|
89
|
+
self._last_active = _last_active
|
90
|
+
self._on_off_device = _on_off_device
|
91
|
+
self._opentherm_device = _opentherm_device
|
92
|
+
self._schedule_old_states = _schedule_old_states
|
93
|
+
self.gateway_id = gateway_id
|
94
|
+
self.loc_data = loc_data
|
95
|
+
self.smile_fw_version = smile_fw_version
|
96
|
+
self.smile_hostname = smile_hostname
|
97
|
+
self.smile_hw_version = smile_hw_version
|
98
|
+
self.smile_legacy = smile_legacy
|
99
|
+
self.smile_mac_address = smile_mac_address
|
100
|
+
self.smile_model = smile_model
|
101
|
+
self.smile_name = smile_name
|
102
|
+
self.smile_type = smile_type
|
103
|
+
self.smile_version = smile_version
|
104
|
+
|
105
|
+
self._heater_id: str
|
106
|
+
self._cooling_enabled = False
|
107
|
+
|
108
|
+
async def full_update_device(self) -> None:
|
109
|
+
"""Perform a first fetch of all XML data, needed for initialization."""
|
110
|
+
self._domain_objects = await self._request(DOMAIN_OBJECTS)
|
111
|
+
self._get_plugwise_notifications()
|
112
|
+
|
113
|
+
def get_all_devices(self) -> None:
|
114
|
+
"""Determine the evices present from the obtained XML-data.
|
115
|
+
|
116
|
+
Run this functions once to gather the initial device configuration,
|
117
|
+
then regularly run async_update() to refresh the device data.
|
118
|
+
"""
|
119
|
+
# Gather all the devices and their initial data
|
120
|
+
self._all_appliances()
|
121
|
+
if self._is_thermostat:
|
122
|
+
if self.smile(ADAM):
|
123
|
+
self._scan_thermostats()
|
124
|
+
# Collect a list of thermostats with offset-capability
|
125
|
+
self.therms_with_offset_func = (
|
126
|
+
self._get_appliances_with_offset_functionality()
|
127
|
+
)
|
128
|
+
|
129
|
+
# Collect and add switching- and/or pump-group devices
|
130
|
+
if group_data := self._get_group_switches():
|
131
|
+
self.gw_devices.update(group_data)
|
132
|
+
|
133
|
+
# Collect the remaining data for all devices
|
134
|
+
self._all_device_data()
|
135
|
+
|
136
|
+
async def async_update(self) -> PlugwiseData:
|
137
|
+
"""Perform an incremental update for updating the various device states."""
|
138
|
+
# Perform a full update at day-change
|
139
|
+
self.gw_data: GatewayData = {}
|
140
|
+
self.gw_devices: dict[str, DeviceData] = {}
|
141
|
+
await self.full_update_device()
|
142
|
+
self.get_all_devices()
|
143
|
+
|
144
|
+
if "heater_id" in self.gw_data:
|
145
|
+
self._heater_id = self.gw_data["heater_id"]
|
146
|
+
if "cooling_enabled" in self.gw_devices[self._heater_id]["binary_sensors"]:
|
147
|
+
self._cooling_enabled = self.gw_devices[self._heater_id]["binary_sensors"]["cooling_enabled"]
|
148
|
+
|
149
|
+
return PlugwiseData(self.gw_data, self.gw_devices)
|
150
|
+
|
151
|
+
########################################################################################################
|
152
|
+
### API Set and HA Service-related Functions ###
|
153
|
+
########################################################################################################
|
154
|
+
|
155
|
+
async def set_schedule_state(
|
156
|
+
self,
|
157
|
+
loc_id: str,
|
158
|
+
new_state: str,
|
159
|
+
name: str | None = None,
|
160
|
+
) -> None:
|
161
|
+
"""Activate/deactivate the Schedule, with the given name, on the relevant Thermostat.
|
162
|
+
|
163
|
+
Determined from - DOMAIN_OBJECTS.
|
164
|
+
Used in HA Core to set the hvac_mode: in practice switch between schedule on - off.
|
165
|
+
"""
|
166
|
+
# Input checking
|
167
|
+
if new_state not in ["on", "off"]:
|
168
|
+
raise PlugwiseError("Plugwise: invalid schedule state.")
|
169
|
+
|
170
|
+
# Translate selection of Off-schedule-option to disabling the active schedule
|
171
|
+
if name == OFF:
|
172
|
+
new_state = "off"
|
173
|
+
|
174
|
+
# Handle no schedule-name / Off-schedule provided
|
175
|
+
if name is None or name == OFF:
|
176
|
+
if schedule_name := self._last_active[loc_id]:
|
177
|
+
name = schedule_name
|
178
|
+
else:
|
179
|
+
return
|
180
|
+
|
181
|
+
assert isinstance(name, str)
|
182
|
+
schedule_rule = self._rule_ids_by_name(name, loc_id)
|
183
|
+
# Raise an error when the schedule name does not exist
|
184
|
+
if not schedule_rule or schedule_rule is None:
|
185
|
+
raise PlugwiseError("Plugwise: no schedule with this name available.")
|
186
|
+
|
187
|
+
# If no state change is requested, do nothing
|
188
|
+
if new_state == self._schedule_old_states[loc_id][name]:
|
189
|
+
return
|
190
|
+
|
191
|
+
schedule_rule_id: str = next(iter(schedule_rule))
|
192
|
+
template = (
|
193
|
+
'<template tag="zone_preset_based_on_time_and_presence_with_override" />'
|
194
|
+
)
|
195
|
+
if self.smile(ANNA):
|
196
|
+
locator = f'.//*[@id="{schedule_rule_id}"]/template'
|
197
|
+
template_id = self._domain_objects.find(locator).attrib["id"]
|
198
|
+
template = f'<template id="{template_id}" />'
|
199
|
+
|
200
|
+
contexts = self.determine_contexts(loc_id, name, new_state, schedule_rule_id)
|
201
|
+
uri = f"{RULES};id={schedule_rule_id}"
|
202
|
+
data = (
|
203
|
+
f'<rules><rule id="{schedule_rule_id}"><name><![CDATA[{name}]]></name>'
|
204
|
+
f"{template}{contexts}</rule></rules>"
|
205
|
+
)
|
206
|
+
|
207
|
+
await self._request(uri, method="put", data=data)
|
208
|
+
self._schedule_old_states[loc_id][name] = new_state
|
209
|
+
|
210
|
+
def determine_contexts(
|
211
|
+
self, loc_id: str, name: str, state: str, sched_id: str
|
212
|
+
) -> etree:
|
213
|
+
"""Helper-function for set_schedule_state()."""
|
214
|
+
locator = f'.//*[@id="{sched_id}"]/contexts'
|
215
|
+
contexts = self._domain_objects.find(locator)
|
216
|
+
locator = f'.//*[@id="{loc_id}"].../...'
|
217
|
+
if (subject := contexts.find(locator)) is None:
|
218
|
+
subject = f'<context><zone><location id="{loc_id}" /></zone></context>'
|
219
|
+
subject = etree.fromstring(subject)
|
220
|
+
|
221
|
+
if state == "off":
|
222
|
+
self._last_active[loc_id] = name
|
223
|
+
contexts.remove(subject)
|
224
|
+
if state == "on":
|
225
|
+
contexts.append(subject)
|
226
|
+
|
227
|
+
return etree.tostring(contexts, encoding="unicode").rstrip()
|
228
|
+
|
229
|
+
async def set_preset(self, loc_id: str, preset: str) -> None:
|
230
|
+
"""Set the given Preset on the relevant Thermostat - from LOCATIONS."""
|
231
|
+
if (presets := self._presets(loc_id)) is None:
|
232
|
+
raise PlugwiseError("Plugwise: no presets available.") # pragma: no cover
|
233
|
+
if preset not in list(presets):
|
234
|
+
raise PlugwiseError("Plugwise: invalid preset.")
|
235
|
+
|
236
|
+
current_location = self._domain_objects.find(f'location[@id="{loc_id}"]')
|
237
|
+
location_name = current_location.find("name").text
|
238
|
+
location_type = current_location.find("type").text
|
239
|
+
|
240
|
+
uri = f"{LOCATIONS};id={loc_id}"
|
241
|
+
data = (
|
242
|
+
"<locations><location"
|
243
|
+
f' id="{loc_id}"><name>{location_name}</name><type>{location_type}'
|
244
|
+
f"</type><preset>{preset}</preset></location></locations>"
|
245
|
+
)
|
246
|
+
|
247
|
+
await self._request(uri, method="put", data=data)
|
248
|
+
|
249
|
+
async def set_temperature(self, loc_id: str, items: dict[str, float]) -> None:
|
250
|
+
"""Set the given Temperature on the relevant Thermostat."""
|
251
|
+
setpoint: float | None = None
|
252
|
+
|
253
|
+
if "setpoint" in items:
|
254
|
+
setpoint = items["setpoint"]
|
255
|
+
|
256
|
+
if self.smile(ANNA) and self._cooling_present:
|
257
|
+
if "setpoint_high" not in items:
|
258
|
+
raise PlugwiseError
|
259
|
+
tmp_setpoint_high = items["setpoint_high"]
|
260
|
+
tmp_setpoint_low = items["setpoint_low"]
|
261
|
+
if self._cooling_enabled: # in cooling mode
|
262
|
+
setpoint = tmp_setpoint_high
|
263
|
+
if tmp_setpoint_low != MIN_SETPOINT:
|
264
|
+
raise PlugwiseError(
|
265
|
+
"Plugwise: heating setpoint cannot be changed when in cooling mode"
|
266
|
+
)
|
267
|
+
else: # in heating mode
|
268
|
+
setpoint = tmp_setpoint_low
|
269
|
+
if tmp_setpoint_high != MAX_SETPOINT:
|
270
|
+
raise PlugwiseError(
|
271
|
+
"Plugwise: cooling setpoint cannot be changed when in heating mode"
|
272
|
+
)
|
273
|
+
|
274
|
+
if setpoint is None:
|
275
|
+
raise PlugwiseError # pragma: no cover"
|
276
|
+
|
277
|
+
temperature = str(setpoint)
|
278
|
+
uri = self._thermostat_uri(loc_id)
|
279
|
+
data = (
|
280
|
+
"<thermostat_functionality><setpoint>"
|
281
|
+
f"{temperature}</setpoint></thermostat_functionality>"
|
282
|
+
)
|
283
|
+
|
284
|
+
await self._request(uri, method="put", data=data)
|
285
|
+
|
286
|
+
async def set_number_setpoint(self, key: str, temperature: float) -> None:
|
287
|
+
"""Set the max. Boiler or DHW setpoint on the Central Heating boiler."""
|
288
|
+
temp = str(temperature)
|
289
|
+
thermostat_id: str | None = None
|
290
|
+
locator = f'appliance[@id="{self._heater_id}"]/actuator_functionalities/thermostat_functionality'
|
291
|
+
if th_func_list := self._domain_objects.findall(locator):
|
292
|
+
for th_func in th_func_list:
|
293
|
+
if th_func.find("type").text == key:
|
294
|
+
thermostat_id = th_func.attrib["id"]
|
295
|
+
|
296
|
+
if thermostat_id is None:
|
297
|
+
raise PlugwiseError
|
298
|
+
|
299
|
+
uri = f"{APPLIANCES};id={self._heater_id}/thermostat;id={thermostat_id}"
|
300
|
+
data = f"<thermostat_functionality><setpoint>{temp}</setpoint></thermostat_functionality>"
|
301
|
+
await self._request(uri, method="put", data=data)
|
302
|
+
|
303
|
+
async def set_temperature_offset(self, dev_id: str, offset: float) -> None:
|
304
|
+
"""Set the Temperature offset for thermostats that support this feature."""
|
305
|
+
if dev_id not in self.therms_with_offset_func:
|
306
|
+
raise PlugwiseError
|
307
|
+
|
308
|
+
value = str(offset)
|
309
|
+
uri = f"{APPLIANCES};id={dev_id}/offset;type=temperature_offset"
|
310
|
+
data = f"<offset_functionality><offset>{value}</offset></offset_functionality>"
|
311
|
+
|
312
|
+
await self._request(uri, method="put", data=data)
|
313
|
+
|
314
|
+
async def set_switch_state(
|
315
|
+
self, appl_id: str, members: list[str] | None, model: str, state: str
|
316
|
+
) -> None:
|
317
|
+
"""Set the given State of the relevant Switch."""
|
318
|
+
switch = Munch()
|
319
|
+
switch.actuator = "actuator_functionalities"
|
320
|
+
switch.device = "relay"
|
321
|
+
switch.func_type = "relay_functionality"
|
322
|
+
switch.func = "state"
|
323
|
+
if model == "dhw_cm_switch":
|
324
|
+
switch.device = "toggle"
|
325
|
+
switch.func_type = "toggle_functionality"
|
326
|
+
switch.act_type = "domestic_hot_water_comfort_mode"
|
327
|
+
|
328
|
+
if model == "cooling_ena_switch":
|
329
|
+
switch.device = "toggle"
|
330
|
+
switch.func_type = "toggle_functionality"
|
331
|
+
switch.act_type = "cooling_enabled"
|
332
|
+
|
333
|
+
if model == "lock":
|
334
|
+
switch.func = "lock"
|
335
|
+
state = "false" if state == "off" else "true"
|
336
|
+
|
337
|
+
if members is not None:
|
338
|
+
return await self._set_groupswitch_member_state(members, state, switch)
|
339
|
+
|
340
|
+
locator = f'appliance[@id="{appl_id}"]/{switch.actuator}/{switch.func_type}'
|
341
|
+
found: list[etree] = self._domain_objects.findall(locator)
|
342
|
+
for item in found:
|
343
|
+
if (sw_type := item.find("type")) is not None:
|
344
|
+
if sw_type.text == switch.act_type:
|
345
|
+
switch_id = item.attrib["id"]
|
346
|
+
else:
|
347
|
+
switch_id = item.attrib["id"]
|
348
|
+
break
|
349
|
+
|
350
|
+
uri = f"{APPLIANCES};id={appl_id}/{switch.device};id={switch_id}"
|
351
|
+
data = f"<{switch.func_type}><{switch.func}>{state}</{switch.func}></{switch.func_type}>"
|
352
|
+
|
353
|
+
if model == "relay":
|
354
|
+
locator = (
|
355
|
+
f'appliance[@id="{appl_id}"]/{switch.actuator}/{switch.func_type}/lock'
|
356
|
+
)
|
357
|
+
# Don't bother switching a relay when the corresponding lock-state is true
|
358
|
+
if self._domain_objects.find(locator).text == "true":
|
359
|
+
raise PlugwiseError
|
360
|
+
|
361
|
+
await self._request(uri, method="put", data=data)
|
362
|
+
|
363
|
+
async def _set_groupswitch_member_state(
|
364
|
+
self, members: list[str], state: str, switch: Munch
|
365
|
+
) -> None:
|
366
|
+
"""Helper-function for set_switch_state().
|
367
|
+
|
368
|
+
Set the given State of the relevant Switch within a group of members.
|
369
|
+
"""
|
370
|
+
for member in members:
|
371
|
+
locator = f'appliance[@id="{member}"]/{switch.actuator}/{switch.func_type}'
|
372
|
+
switch_id = self._domain_objects.find(locator).attrib["id"]
|
373
|
+
uri = f"{APPLIANCES};id={member}/{switch.device};id={switch_id}"
|
374
|
+
data = f"<{switch.func_type}><{switch.func}>{state}</{switch.func}></{switch.func_type}>"
|
375
|
+
|
376
|
+
await self._request(uri, method="put", data=data)
|
377
|
+
|
378
|
+
async def set_gateway_mode(self, mode: str) -> None:
|
379
|
+
"""Set the gateway mode."""
|
380
|
+
if mode not in self._gw_allowed_modes:
|
381
|
+
raise PlugwiseError
|
382
|
+
|
383
|
+
end_time = "2037-04-21T08:00:53.000Z"
|
384
|
+
valid = ""
|
385
|
+
if mode == "away":
|
386
|
+
time_1 = self._domain_objects.find("./gateway/time").text
|
387
|
+
away_time = dt.datetime.fromisoformat(time_1).astimezone(dt.UTC).isoformat(timespec="milliseconds").replace("+00:00", "Z")
|
388
|
+
valid = (
|
389
|
+
f"<valid_from>{away_time}</valid_from><valid_to>{end_time}</valid_to>"
|
390
|
+
)
|
391
|
+
if mode == "vacation":
|
392
|
+
time_2 = str(dt.date.today() - dt.timedelta(1))
|
393
|
+
vacation_time = time_2 + "T23:00:00.000Z"
|
394
|
+
valid = f"<valid_from>{vacation_time}</valid_from><valid_to>{end_time}</valid_to>"
|
395
|
+
|
396
|
+
uri = f"{APPLIANCES};id={self.gateway_id}/gateway_mode_control"
|
397
|
+
data = f"<gateway_mode_control_functionality><mode>{mode}</mode>{valid}</gateway_mode_control_functionality>"
|
398
|
+
|
399
|
+
await self._request(uri, method="put", data=data)
|
400
|
+
|
401
|
+
async def set_regulation_mode(self, mode: str) -> None:
|
402
|
+
"""Set the heating regulation mode."""
|
403
|
+
if mode not in self._reg_allowed_modes:
|
404
|
+
raise PlugwiseError
|
405
|
+
|
406
|
+
uri = f"{APPLIANCES};type=gateway/regulation_mode_control"
|
407
|
+
duration = ""
|
408
|
+
if "bleeding" in mode:
|
409
|
+
duration = "<duration>300</duration>"
|
410
|
+
data = f"<regulation_mode_control_functionality>{duration}<mode>{mode}</mode></regulation_mode_control_functionality>"
|
411
|
+
|
412
|
+
await self._request(uri, method="put", data=data)
|
413
|
+
|
414
|
+
async def set_dhw_mode(self, mode: str) -> None:
|
415
|
+
"""Set the domestic hot water heating regulation mode."""
|
416
|
+
if mode not in self._dhw_allowed_modes:
|
417
|
+
raise PlugwiseError("Plugwise: invalid dhw mode.")
|
418
|
+
|
419
|
+
uri = f"{APPLIANCES};type=heater_central/domestic_hot_water_mode_control"
|
420
|
+
data = f"<domestic_hot_water_mode_control_functionality><mode>{mode}</mode></domestic_hot_water_mode_control_functionality>"
|
421
|
+
|
422
|
+
await self._request(uri, method="put", data=data)
|
423
|
+
|
424
|
+
async def delete_notification(self) -> None:
|
425
|
+
"""Delete the active Plugwise Notification."""
|
426
|
+
await self._request(NOTIFICATIONS, method="delete")
|