File: smile.py

package info (click to toggle)
python-plugwise 1.7.4-1
  • links: PTS, VCS
  • area: main
  • in suites: sid, trixie
  • size: 6,080 kB
  • sloc: xml: 90,006; python: 5,344; sh: 326; makefile: 2
file content (497 lines) | stat: -rw-r--r-- 19,550 bytes parent folder | download
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
"""Use of this source code is governed by the MIT license found in the LICENSE file.

Plugwise backend module for Home Assistant Core.
"""

from __future__ import annotations

from collections.abc import Awaitable, Callable
import datetime as dt
from typing import Any

from plugwise.constants import (
    ADAM,
    ANNA,
    APPLIANCES,
    DOMAIN_OBJECTS,
    GATEWAY_REBOOT,
    LOCATIONS,
    MAX_SETPOINT,
    MIN_SETPOINT,
    NONE,
    NOTIFICATIONS,
    OFF,
    RULES,
    GwEntityData,
    ThermoLoc,
)
from plugwise.data import SmileData
from plugwise.exceptions import ConnectionFailedError, DataMissingError, PlugwiseError

from defusedxml import ElementTree as etree

# Dict as class
from munch import Munch
from packaging.version import Version


class SmileAPI(SmileData):
    """The Plugwise SmileAPI helper class for actual Plugwise devices."""

    # pylint: disable=too-many-instance-attributes, too-many-public-methods

    def __init__(
        self,
        _cooling_present: bool,
        _elga: bool,
        _is_thermostat: bool,
        _last_active: dict[str, str | None],
        _loc_data: dict[str, ThermoLoc],
        _on_off_device: bool,
        _opentherm_device: bool,
        _request: Callable[..., Awaitable[Any]],
        _schedule_old_states: dict[str, dict[str, str]],
        smile_hostname: str | None,
        smile_hw_version: str | None,
        smile_mac_address: str | None,
        smile_model: str,
        smile_model_id: str | None,
        smile_name: str,
        smile_type: str,
        smile_version: Version,
    ) -> None:
        """Set the constructor for this class."""
        super().__init__()
        self._cooling_present = _cooling_present
        self._elga = _elga
        self._is_thermostat = _is_thermostat
        self._last_active = _last_active
        self._loc_data = _loc_data
        self._on_off_device = _on_off_device
        self._opentherm_device = _opentherm_device
        self._request = _request
        self._schedule_old_states = _schedule_old_states
        self.smile_hostname = smile_hostname
        self.smile_hw_version = smile_hw_version
        self.smile_mac_address = smile_mac_address
        self.smile_model = smile_model
        self.smile_model_id = smile_model_id
        self.smile_name = smile_name
        self.smile_type = smile_type
        self.smile_version = smile_version
        self.therms_with_offset_func: list[str] = []

    @property
    def cooling_present(self) -> bool:
        """Return the cooling capability."""
        return self._cooling_present

    async def full_xml_update(self) -> None:
        """Perform a first fetch of the Plugwise server XML data."""
        self._domain_objects = await self._request(DOMAIN_OBJECTS)
        self._get_plugwise_notifications()

    def get_all_gateway_entities(self) -> None:
        """Collect the Plugwise gateway entities and their data and states from the received raw XML-data.

        First, collect all the connected entities and their initial data.
        If a thermostat-gateway, collect a list of thermostats with offset-capability.
        Collect and add switching- and/or pump-group entities.
        Finally, collect the data and states for each entity.
        """
        self._all_appliances()
        if self._is_thermostat:
            self.therms_with_offset_func = (
                self._get_appliances_with_offset_functionality()
            )
            if self.smile(ADAM):
                self._scan_thermostats()

        if group_data := self._get_group_switches():
            self.gw_entities.update(group_data)

        self._all_entity_data()

    async def async_update(self) -> dict[str, GwEntityData]:
        """Perform an full update: re-collect all gateway entities and their data and states.

        Any change in the connected entities will be detected immediately.
        """
        self._zones = {}
        self.gw_entities = {}
        try:
            await self.full_xml_update()
            self.get_all_gateway_entities()
            # Set self._cooling_enabled - required for set_temperature(),
            # also, check for a failed data-retrieval
            if self.heater_id != NONE:
                heat_cooler = self.gw_entities[self.heater_id]
                if (
                    "binary_sensors" in heat_cooler
                    and "cooling_enabled" in heat_cooler["binary_sensors"]
                ):
                    self._cooling_enabled = heat_cooler["binary_sensors"][
                        "cooling_enabled"
                    ]
            else:  # cover failed data-retrieval for P1
                _ = self.gw_entities[self.gateway_id]["location"]
        except KeyError as err:
            raise DataMissingError("No Plugwise actual data received") from err

        return self.gw_entities

    ########################################################################################################
    ###  API Set and HA Service-related Functions                                                        ###
    ########################################################################################################

    async def delete_notification(self) -> None:
        """Delete the active Plugwise Notification."""
        await self.call_request(NOTIFICATIONS, method="delete")

    async def reboot_gateway(self) -> None:
        """Reboot the Gateway."""
        await self.call_request(GATEWAY_REBOOT, method="post")

    async def set_number(
        self,
        dev_id: str,
        key: str,
        temperature: float,
    ) -> None:
        """Set the maximum boiler- or DHW-setpoint on the Central Heating boiler or the temperature-offset on a Thermostat."""
        match key:
            case "temperature_offset":
                await self.set_offset(dev_id, temperature)
                return
            case "max_dhw_temperature":
                key = "domestic_hot_water_setpoint"

        temp = str(temperature)
        thermostat_id: str | None = None
        locator = f'appliance[@id="{self._heater_id}"]/actuator_functionalities/thermostat_functionality'
        if th_func_list := self._domain_objects.findall(locator):
            for th_func in th_func_list:
                if th_func.find("type").text == key:
                    thermostat_id = th_func.attrib["id"]

        if thermostat_id is None:
            raise PlugwiseError(f"Plugwise: cannot change setpoint, {key} not found.")

        data = (
            "<thermostat_functionality>"
            f"<setpoint>{temp}</setpoint>"
            "</thermostat_functionality>"
        )
        uri = f"{APPLIANCES};id={self._heater_id}/thermostat;id={thermostat_id}"
        await self.call_request(uri, method="put", data=data)

    async def set_offset(self, dev_id: str, offset: float) -> None:
        """Set the Temperature offset for thermostats that support this feature."""
        if dev_id not in self.therms_with_offset_func:
            raise PlugwiseError(
                "Plugwise: this device does not have temperature-offset capability."
            )

        value = str(offset)
        data = f"<offset_functionality><offset>{value}</offset></offset_functionality>"
        uri = f"{APPLIANCES};id={dev_id}/offset;type=temperature_offset"
        await self.call_request(uri, method="put", data=data)

    async def set_preset(self, loc_id: str, preset: str) -> None:
        """Set the given Preset on the relevant Thermostat - from LOCATIONS."""
        if (presets := self._presets(loc_id)) is None:
            raise PlugwiseError("Plugwise: no presets available.")  # pragma: no cover
        if preset not in list(presets):
            raise PlugwiseError("Plugwise: invalid preset.")

        current_location = self._domain_objects.find(f'location[@id="{loc_id}"]')
        location_name = current_location.find("name").text
        location_type = current_location.find("type").text
        data = (
            "<locations>"
            f'<location id="{loc_id}">'
            f"<name>{location_name}</name>"
            f"<type>{location_type}</type>"
            f"<preset>{preset}</preset>"
            "</location>"
            "</locations>"
        )
        uri = f"{LOCATIONS};id={loc_id}"
        await self.call_request(uri, method="put", data=data)

    async def set_select(
        self, key: str, loc_id: str, option: str, state: str | None
    ) -> None:
        """Set a dhw/gateway/regulation mode or the thermostat schedule option."""
        match key:
            case "select_dhw_mode":
                await self.set_dhw_mode(option)
            case "select_gateway_mode":
                await self.set_gateway_mode(option)
            case "select_regulation_mode":
                await self.set_regulation_mode(option)
            case "select_schedule":
                # schedule name corresponds to select option
                await self.set_schedule_state(loc_id, state, option)

    async def set_dhw_mode(self, mode: str) -> None:
        """Set the domestic hot water heating regulation mode."""
        if mode not in self._dhw_allowed_modes:
            raise PlugwiseError("Plugwise: invalid dhw mode.")

        data = (
            "<domestic_hot_water_mode_control_functionality>"
            f"<mode>{mode}</mode>"
            "</domestic_hot_water_mode_control_functionality>"
        )
        uri = f"{APPLIANCES};type=heater_central/domestic_hot_water_mode_control"
        await self.call_request(uri, method="put", data=data)

    async def set_gateway_mode(self, mode: str) -> None:
        """Set the gateway mode."""
        if mode not in self._gw_allowed_modes:
            raise PlugwiseError("Plugwise: invalid gateway mode.")

        end_time = "2037-04-21T08:00:53.000Z"
        valid = ""
        if mode == "away":
            time_1 = self._domain_objects.find("./gateway/time").text
            away_time = (
                dt.datetime.fromisoformat(time_1)
                .astimezone(dt.UTC)
                .isoformat(timespec="milliseconds")
                .replace("+00:00", "Z")
            )
            valid = (
                f"<valid_from>{away_time}</valid_from><valid_to>{end_time}</valid_to>"
            )
        if mode == "vacation":
            time_2 = str(dt.date.today() - dt.timedelta(1))
            vacation_time = time_2 + "T23:00:00.000Z"
            valid = f"<valid_from>{vacation_time}</valid_from><valid_to>{end_time}</valid_to>"

        data = (
            "<gateway_mode_control_functionality>"
            f"<mode>{mode}</mode>"
            f"{valid}"
            "</gateway_mode_control_functionality>"
        )
        uri = f"{APPLIANCES};id={self.gateway_id}/gateway_mode_control"
        await self.call_request(uri, method="put", data=data)

    async def set_regulation_mode(self, mode: str) -> None:
        """Set the heating regulation mode."""
        if mode not in self._reg_allowed_modes:
            raise PlugwiseError("Plugwise: invalid regulation mode.")

        duration = ""
        if "bleeding" in mode:
            duration = "<duration>300</duration>"

        data = (
            "<regulation_mode_control_functionality>"
            f"{duration}"
            f"<mode>{mode}</mode>"
            "</regulation_mode_control_functionality>"
        )
        uri = f"{APPLIANCES};type=gateway/regulation_mode_control"
        await self.call_request(uri, method="put", data=data)

    async def set_schedule_state(
        self,
        loc_id: str,
        new_state: str | None,
        name: str | None,
    ) -> None:
        """Activate/deactivate the Schedule, with the given name, on the relevant Thermostat.

        Determined from - DOMAIN_OBJECTS.
        Used in HA Core to set the hvac_mode: in practice switch between schedule on - off.
        """
        # Input checking
        if new_state not in ("on", "off"):
            raise PlugwiseError("Plugwise: invalid schedule state.")

        # Translate selection of Off-schedule-option to disabling the active schedule
        if name == OFF:
            new_state = "off"

        # Handle no schedule-name / Off-schedule provided
        if name is None or name == OFF:
            if schedule_name := self._last_active[loc_id]:
                name = schedule_name
            else:
                return

        assert isinstance(name, str)
        schedule_rule = self._rule_ids_by_name(name, loc_id)
        # Raise an error when the schedule name does not exist
        if not schedule_rule or schedule_rule is None:
            raise PlugwiseError("Plugwise: no schedule with this name available.")

        # If no state change is requested, do nothing
        if new_state == self._schedule_old_states[loc_id][name]:
            return

        schedule_rule_id: str = next(iter(schedule_rule))
        template = (
            '<template tag="zone_preset_based_on_time_and_presence_with_override" />'
        )
        if self.smile(ANNA):
            locator = f'.//*[@id="{schedule_rule_id}"]/template'
            template_id = self._domain_objects.find(locator).attrib["id"]
            template = f'<template id="{template_id}" />'

        contexts = self.determine_contexts(loc_id, name, new_state, schedule_rule_id)
        data = (
            "<rules>"
            f"<rule id='{schedule_rule_id}'>"
            f"<name><![CDATA[{name}]]></name>"
            f"{template}"
            f"{contexts}"
            "</rule>"
            "</rules>"
        )
        uri = f"{RULES};id={schedule_rule_id}"
        await self.call_request(uri, method="put", data=data)
        self._schedule_old_states[loc_id][name] = new_state

    def determine_contexts(
        self, loc_id: str, name: str, state: str, sched_id: str
    ) -> str:
        """Helper-function for set_schedule_state()."""
        locator = f'.//*[@id="{sched_id}"]/contexts'
        contexts = self._domain_objects.find(locator)
        locator = f'.//*[@id="{loc_id}"].../...'
        if (subject := contexts.find(locator)) is None:
            subject = f'<context><zone><location id="{loc_id}" /></zone></context>'
            subject = etree.fromstring(subject)

        if state == "off":
            self._last_active[loc_id] = name
            contexts.remove(subject)
        if state == "on":
            contexts.append(subject)

        return str(etree.tostring(contexts, encoding="unicode").rstrip())

    async def set_switch_state(
        self, appl_id: str, members: list[str] | None, model: str, state: str
    ) -> None:
        """Set the given State of the relevant Switch."""
        switch = Munch()
        switch.actuator = "actuator_functionalities"
        switch.device = "relay"
        switch.func_type = "relay_functionality"
        switch.func = "state"
        if model == "dhw_cm_switch":
            switch.device = "toggle"
            switch.func_type = "toggle_functionality"
            switch.act_type = "domestic_hot_water_comfort_mode"

        if model == "cooling_ena_switch":
            switch.device = "toggle"
            switch.func_type = "toggle_functionality"
            switch.act_type = "cooling_enabled"

        if model == "lock":
            switch.func = "lock"
            state = "false" if state == "off" else "true"

        if members is not None:
            return await self._set_groupswitch_member_state(members, state, switch)

        locator = f'appliance[@id="{appl_id}"]/{switch.actuator}/{switch.func_type}'
        found = self._domain_objects.findall(locator)
        for item in found:
            # multiple types of e.g. toggle_functionality present
            if (sw_type := item.find("type")) is not None:
                if sw_type.text == switch.act_type:
                    switch_id = item.attrib["id"]
                    break
            else:  # actuators with a single item like relay_functionality
                switch_id = item.attrib["id"]

        data = (
            f"<{switch.func_type}>"
            f"<{switch.func}>{state}</{switch.func}>"
            f"</{switch.func_type}>"
        )
        uri = f"{APPLIANCES};id={appl_id}/{switch.device};id={switch_id}"
        if model == "relay":
            locator = (
                f'appliance[@id="{appl_id}"]/{switch.actuator}/{switch.func_type}/lock'
            )
            # Don't bother switching a relay when the corresponding lock-state is true
            if self._domain_objects.find(locator).text == "true":
                raise PlugwiseError("Plugwise: the locked Relay was not switched.")

        await self.call_request(uri, method="put", data=data)

    async def _set_groupswitch_member_state(
        self, members: list[str], state: str, switch: Munch
    ) -> None:
        """Helper-function for set_switch_state().

        Set the given State of the relevant Switch within a group of members.
        """
        for member in members:
            locator = f'appliance[@id="{member}"]/{switch.actuator}/{switch.func_type}'
            switch_id = self._domain_objects.find(locator).attrib["id"]
            uri = f"{APPLIANCES};id={member}/{switch.device};id={switch_id}"
            data = (
                f"<{switch.func_type}>"
                f"<{switch.func}>{state}</{switch.func}>"
                f"</{switch.func_type}>"
            )
            await self.call_request(uri, method="put", data=data)

    async def set_temperature(self, loc_id: str, items: dict[str, float]) -> None:
        """Set the given Temperature on the relevant Thermostat."""
        setpoint: float | None = None

        if "setpoint" in items:
            setpoint = items["setpoint"]

        if self.smile(ANNA) and self._cooling_present:
            if "setpoint_high" not in items:
                raise PlugwiseError(
                    "Plugwise: failed setting temperature: no valid input provided"
                )
            tmp_setpoint_high = items["setpoint_high"]
            tmp_setpoint_low = items["setpoint_low"]
            if self._cooling_enabled:  # in cooling mode
                setpoint = tmp_setpoint_high
                if tmp_setpoint_low != MIN_SETPOINT:
                    raise PlugwiseError(
                        "Plugwise: heating setpoint cannot be changed when in cooling mode"
                    )
            else:  # in heating mode
                setpoint = tmp_setpoint_low
                if tmp_setpoint_high != MAX_SETPOINT:
                    raise PlugwiseError(
                        "Plugwise: cooling setpoint cannot be changed when in heating mode"
                    )

        if setpoint is None:
            raise PlugwiseError(
                "Plugwise: failed setting temperature: no valid input provided"
            )  # pragma: no cover"

        temperature = str(setpoint)
        data = (
            "<thermostat_functionality>"
            f"<setpoint>{temperature}</setpoint>"
            "</thermostat_functionality>"
        )
        uri = self._thermostat_uri(loc_id)
        await self.call_request(uri, method="put", data=data)

    async def call_request(self, uri: str, **kwargs: Any) -> None:
        """ConnectionFailedError wrapper for calling request()."""
        method: str = kwargs["method"]
        data: str | None = kwargs.get("data")
        try:
            await self._request(uri, method=method, data=data)
        except ConnectionFailedError as exc:
            raise ConnectionFailedError from exc