Skip to content

Commit 245889e

Browse files
authored
Merge pull request #196 from plugwise/add_target_high_low
Add setpoint_high, set_point_low for anna with cooling
2 parents e713c0f + cc5f957 commit 245889e

File tree

28 files changed

+9598
-62
lines changed

28 files changed

+9598
-62
lines changed

.github/workflows/verify.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
name: Latest commit
55

66
env:
7-
CACHE_VERSION: 9
7+
CACHE_VERSION: 10
88
DEFAULT_PYTHON: "3.9"
99
PRE_COMMIT_HOME: ~/.cache/pre-commit
1010

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,12 @@
11
# Changelog
22

3+
# v0.19.0: Smile Adam & Anna: cooling-related updates
4+
- Anna: replace `setpoint` with `setpoint_low` and `setpoint_high` when cooling is active
5+
- Anna: update according to recent Anna-with-cooling firmware updates (info provided by Plugwise)
6+
- Anna: handle `cooling_state = on` according to Plugwise specification (`cooling_state = on` and `modulation_level = 100`)
7+
- Move boiler-type detection and cooling-present detection into `_all_device_data()`
8+
- Update/extend testing and corresponding userdata
9+
310
# v0.18.5: Smile bugfix for https://github.com/plugwise/python-plugwise/issues/192
411

512
# v0.18.4: Smile: schedule-related bug-fixes and clean-up

plugwise/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
"""Plugwise module."""
22

3-
__version__ = "0.18.5"
3+
__version__ = "0.19.0"
44

55
from plugwise.smile import Smile
66
from plugwise.stick import Stick

plugwise/constants.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
11
"""Plugwise Stick and Smile constants."""
22
from __future__ import annotations
33

4+
import datetime as dt
45
import logging
56
from typing import Final, TypedDict
67

78
LOGGER = logging.getLogger(__name__)
89

910
# Copied homeassistant.consts
11+
ARBITRARY_DATE: Final = dt.datetime(2022, 5, 14)
1012
ATTR_NAME: Final = "name"
1113
ATTR_STATE: Final = "state"
1214
ATTR_STATE_CLASS: Final = "state_class"
@@ -455,6 +457,7 @@
455457
ATTR_NAME: "water_temperature",
456458
ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS,
457459
},
460+
"cooling_enabled": {ATTR_UNIT_OF_MEASUREMENT: NONE},
458461
"domestic_hot_water_comfort_mode": {
459462
ATTR_NAME: "dhw_cm_switch",
460463
ATTR_UNIT_OF_MEASUREMENT: NONE,
@@ -593,6 +596,8 @@
593596
"outdoor_temperature",
594597
"return_temperature",
595598
"setpoint",
599+
"setpoint_high",
600+
"setpoint_low",
596601
"temperature_difference",
597602
"valve_position",
598603
"water_pressure",
@@ -660,6 +665,7 @@ class SmileSensors(TypedDict, total=False):
660665
battery: float
661666
cooling_activation_outdoor_temperature: float
662667
cooling_deactivation_threshold: float
668+
cooling_enabled: bool
663669
temperature: float
664670
electricity_consumed: float
665671
electricity_consumed_interval: float
@@ -691,6 +697,8 @@ class SmileSensors(TypedDict, total=False):
691697
outdoor_temperature: float
692698
return_temperature: float
693699
setpoint: float
700+
setpoint_high: float
701+
setpoint_low: float
694702
temperature_difference: float
695703
valve_position: float
696704
water_pressure: float

plugwise/helper.py

Lines changed: 108 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -8,19 +8,20 @@
88

99
# This way of importing aiohttp is because of patch/mocking in testing (aiohttp timeouts)
1010
from aiohttp import BasicAuth, ClientError, ClientResponse, ClientSession, ClientTimeout
11+
12+
# Time related
13+
from dateutil import tz
1114
from dateutil.parser import parse
1215
from defusedxml import ElementTree as etree
1316
from munch import Munch
14-
15-
# Time related
16-
import pytz
1717
from semver import VersionInfo
1818

1919
from .constants import (
2020
APPLIANCES,
2121
ATTR_NAME,
2222
ATTR_UNIT_OF_MEASUREMENT,
2323
BINARY_SENSORS,
24+
DAYS,
2425
DEVICE_MEASUREMENTS,
2526
ENERGY_KILO_WATT_HOUR,
2627
ENERGY_WATT_HOUR,
@@ -49,10 +50,15 @@
4950
from .exceptions import (
5051
InvalidAuthentication,
5152
InvalidXMLError,
52-
PlugwiseException,
53+
PlugwiseError,
5354
ResponseError,
5455
)
55-
from .util import escape_illegal_xml_characters, format_measure, version_to_model
56+
from .util import (
57+
escape_illegal_xml_characters,
58+
format_measure,
59+
in_between,
60+
version_to_model,
61+
)
5662

5763

5864
def update_helper(
@@ -87,6 +93,46 @@ def check_model(name: str | None, vendor_name: str | None) -> str | None:
8793
return name
8894

8995

96+
def schedules_temps(
97+
schedules: dict[str, dict[str, list[float]]], name: str
98+
) -> list[float] | None:
99+
"""Helper-function for schedules().
100+
Obtain the schedule temperature of the schedule.
101+
"""
102+
if name == NONE:
103+
return None # pragma: no cover
104+
105+
schedule_list: list[tuple[int, dt.time, list[float]]] = []
106+
for period, temp in schedules[name].items():
107+
moment, dummy = period.split(",")
108+
moment_cleaned = moment.replace("[", "").split(" ")
109+
day_nr = DAYS[moment_cleaned[0]]
110+
start_time = dt.datetime.strptime(moment_cleaned[1], "%H:%M").time()
111+
tmp_list: tuple[int, dt.time, list[float]] = (
112+
day_nr,
113+
start_time,
114+
[temp[0], temp[1]],
115+
)
116+
schedule_list.append(tmp_list)
117+
118+
length = len(schedule_list)
119+
schedule_list = sorted(schedule_list)
120+
for i in range(length):
121+
j = (i + 1) % (length)
122+
now = dt.datetime.now().time()
123+
today = dt.datetime.now().weekday()
124+
day_0 = schedule_list[i][0]
125+
day_1 = schedule_list[j][0]
126+
if j < i:
127+
day_1 = schedule_list[i][0] + 2
128+
time_0 = schedule_list[i][1]
129+
time_1 = schedule_list[j][1]
130+
if in_between(today, day_0, day_1, now, time_0, time_1):
131+
return schedule_list[i][2]
132+
133+
return None # pragma: no cover
134+
135+
90136
def power_data_local_format(
91137
attrs: dict[str, str], key_string: str, val: str
92138
) -> float | int | bool:
@@ -223,7 +269,7 @@ async def _request(
223269
except ClientError as err: # ClientError is an ancestor class of ServerTimeoutError
224270
if retry < 1:
225271
LOGGER.error("Failed sending %s %s to Plugwise Smile", method, command)
226-
raise PlugwiseException(
272+
raise PlugwiseError(
227273
"Plugwise connection error, check log for more info."
228274
) from err
229275
return await self._request(command, retry - 1)
@@ -243,6 +289,8 @@ def __init__(self) -> None:
243289
self._appl_data: dict[str, ApplianceData] = {}
244290
self._appliances: etree
245291
self._allowed_modes: list[str] = []
292+
self._adam_cooling_enabled = False
293+
self._anna_cooling_derived = False
246294
self._anna_cooling_present = False
247295
self._cooling_activation_outdoor_temp: float
248296
self._cooling_deactivation_threshold: float
@@ -263,7 +311,8 @@ def __init__(self) -> None:
263311
self._stretch_v3 = False
264312
self._thermo_locs: dict[str, ThermoLoc] = {}
265313

266-
self.cooling_active = False
314+
self.anna_cooling_enabled = False
315+
self.anna_cool_ena_indication: bool | None = None
267316
self.gateway_id: str
268317
self.gw_data: GatewayData = {}
269318
self.gw_devices: dict[str, DeviceData] = {}
@@ -428,15 +477,14 @@ def _appliance_info_finder(self, appliance: etree, appl: Munch) -> Munch:
428477
):
429478
appl.zigbee_mac = found.find("mac_address").text
430479

431-
# Adam: check for cooling capability and active heating/cooling operation-mode
480+
# Adam: check for active heating/cooling operation-mode
432481
mode_list: list[str] = []
433482
locator = "./actuator_functionalities/regulation_mode_control_functionality"
434483
if (search := appliance.find(locator)) is not None:
435-
self.cooling_active = search.find("mode").text == "cooling"
484+
self._adam_cooling_enabled = search.find("mode").text == "cooling"
436485
if search.find("allowed_modes") is not None:
437486
for mode in search.find("allowed_modes"):
438487
mode_list.append(mode.text)
439-
self._cooling_present = "cooling" in mode_list
440488
self._allowed_modes = mode_list
441489

442490
return appl
@@ -745,7 +793,6 @@ def _appliance_measurements(
745793
# Anna: save cooling-related measurements for later use
746794
# Use the local outdoor temperature as reference for turning cooling on/off
747795
if measurement == "cooling_activation_outdoor_temperature":
748-
self._anna_cooling_present = self._cooling_present = True
749796
self._cooling_activation_outdoor_temp = data[measurement] # type: ignore [literal-required]
750797
if measurement == "cooling_deactivation_threshold":
751798
self._cooling_deactivation_threshold = data[measurement] # type: ignore [literal-required]
@@ -806,6 +853,21 @@ def _get_appliance_data(self, d_id: str) -> DeviceData:
806853
if "temperature" in data:
807854
data.pop("heating_state", None)
808855

856+
if d_id == self._heater_id:
857+
# Use cooling_enabled point-log to set self.anna_cool_ena_indication to True, then remove
858+
if self._anna_cooling_present:
859+
self.anna_cool_ena_indication = False
860+
if "cooling_enabled" in data:
861+
self.anna_cool_ena_indication = True
862+
self.anna_cooling_enabled = data["cooling_enabled"]
863+
data.pop("cooling_enabled", None)
864+
865+
# Create updated cooling_state based on cooling_state = on and modulation = 1.0
866+
if "cooling_state" in data:
867+
data["cooling_state"] = (
868+
data["cooling_state"] and data["modulation_level"] == 100
869+
)
870+
809871
return data
810872

811873
def _rank_thermostat(
@@ -1023,7 +1085,7 @@ def _preset(self, loc_id: str) -> str | None:
10231085

10241086
def _schedules_legacy(
10251087
self, avail: list[str], sel: str
1026-
) -> tuple[list[str], str, None]:
1088+
) -> tuple[list[str], str, None, None]:
10271089
"""Helper-function for _schedules().
10281090
Collect available schedules/schedules for the legacy thermostat.
10291091
"""
@@ -1046,16 +1108,19 @@ def _schedules_legacy(
10461108
if active:
10471109
sel = name
10481110

1049-
return avail, sel, None
1111+
return avail, sel, None, None
10501112

1051-
def _schedules(self, location: str) -> tuple[list[str], str, str | None]:
1113+
def _schedules(
1114+
self, location: str
1115+
) -> tuple[list[str], str, list[float] | None, str | None]:
10521116
"""Helper-function for smile.py: _device_data_climate().
10531117
Obtain the available schedules/schedules. Adam: a schedule can be connected to more than one location.
10541118
NEW: when a location_id is present then the schedule is active. Valid for both Adam and non-legacy Anna.
10551119
"""
10561120
available: list[str] = [NONE]
10571121
last_used: str | None = None
10581122
rule_ids: dict[str, str] = {}
1123+
schedule_temperatures: list[float] | None = None
10591124
selected = NONE
10601125

10611126
# Legacy Anna schedule, only one schedule allowed
@@ -1069,24 +1134,47 @@ def _schedules(self, location: str) -> tuple[list[str], str, str | None]:
10691134

10701135
tag = "zone_preset_based_on_time_and_presence_with_override"
10711136
if not (rule_ids := self._rule_ids_by_tag(tag, location)):
1072-
return available, selected, None
1137+
return available, selected, schedule_temperatures, None
10731138

1074-
schedules: list[str] = []
1139+
schedules: dict[str, dict[str, list[float]]] = {}
10751140
for rule_id, loc_id in rule_ids.items():
10761141
name = self._domain_objects.find(f'./rule[@id="{rule_id}"]/name').text
1142+
schedule: dict[str, list[float]] = {}
1143+
# Only process the active schedule in detail for Anna with cooling
1144+
if self._anna_cooling_present and loc_id != NONE:
1145+
locator = f'./rule[@id="{rule_id}"]/directives'
1146+
directives = self._domain_objects.find(locator)
1147+
for directive in directives:
1148+
entry = directive.find("then").attrib
1149+
keys, dummy = zip(*entry.items())
1150+
if str(keys[0]) == "preset":
1151+
schedule[directive.attrib["time"]] = [
1152+
float(self._presets(loc_id)[entry["preset"]][0]),
1153+
float(self._presets(loc_id)[entry["preset"]][1]),
1154+
]
1155+
else:
1156+
schedule[directive.attrib["time"]] = [
1157+
float(entry["heating_setpoint"]),
1158+
float(entry["cooling_setpoint"]),
1159+
]
1160+
10771161
available.append(name)
10781162
if location == loc_id:
10791163
selected = name
10801164
self._last_active[location] = selected
1081-
schedules.append(name)
1165+
schedules[name] = schedule
10821166

10831167
if schedules:
10841168
available.remove(NONE)
10851169
last_used = self._last_used_schedule(location, schedules)
1170+
if self._anna_cooling_present and last_used in schedules:
1171+
schedule_temperatures = schedules_temps(schedules, last_used)
10861172

1087-
return available, selected, last_used
1173+
return available, selected, schedule_temperatures, last_used
10881174

1089-
def _last_used_schedule(self, loc_id: str, schedules: list[str]) -> str | None:
1175+
def _last_used_schedule(
1176+
self, loc_id: str, schedules: dict[str, dict[str, list[float]]]
1177+
) -> str | None:
10901178
"""Helper-function for smile.py: _device_data_climate().
10911179
Determine the last-used schedule based on the location or the modified date.
10921180
"""
@@ -1100,7 +1188,7 @@ def _last_used_schedule(self, loc_id: str, schedules: list[str]) -> str | None:
11001188
if not schedules:
11011189
return last_used # pragma: no cover
11021190

1103-
epoch = dt.datetime(1970, 1, 1, tzinfo=pytz.utc)
1191+
epoch = dt.datetime(1970, 1, 1, tzinfo=tz.tzutc())
11041192
schedules_dates: dict[str, float] = {}
11051193

11061194
for name in schedules:

0 commit comments

Comments
 (0)