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