|
1 | | -"""Use of this source code is governed by the MIT license found in the LICENSE file. |
2 | 1 |
|
3 | | -Plugwise Smile protocol data-collection helpers. |
4 | | -""" |
5 | 2 |
|
6 | | -from __future__ import annotations |
7 | 3 |
|
8 | | -import re |
9 | | - |
10 | | -from plugwise.constants import ( |
11 | | - ADAM, |
12 | | - ANNA, |
13 | | - MAX_SETPOINT, |
14 | | - MIN_SETPOINT, |
15 | | - NONE, |
16 | | - OFF, |
17 | | - ActuatorData, |
18 | | - GwEntityData, |
19 | | -) |
20 | | -from plugwise.helper import SmileHelper |
21 | | -from plugwise.util import remove_empty_platform_dicts |
22 | | - |
23 | | - |
24 | | -class SmileData(SmileHelper): |
25 | | - """The Plugwise Smile main class.""" |
26 | | - |
27 | | - def __init__(self) -> None: |
28 | | - """Init.""" |
29 | | - SmileHelper.__init__(self) |
30 | | - |
31 | | - def _all_entity_data(self) -> None: |
32 | | - """Helper-function for get_all_gateway_entities(). |
33 | | -
|
34 | | - Collect data for each entity and add to self.gw_data and self.gw_entities. |
35 | | - """ |
36 | | - self._update_gw_entities() |
37 | | - if self.smile(ADAM): |
38 | | - self._update_zones() |
39 | | - self.gw_entities.update(self._zones) |
40 | | - |
41 | | - self.gw_data.update( |
42 | | - { |
43 | | - "gateway_id": self.gateway_id, |
44 | | - "item_count": self._count, |
45 | | - "notifications": self._notifications, |
46 | | - "reboot": True, |
47 | | - "smile_name": self.smile_name, |
48 | | - } |
49 | | - ) |
50 | | - if self._is_thermostat: |
51 | | - self.gw_data.update( |
52 | | - {"heater_id": self._heater_id, "cooling_present": self._cooling_present} |
53 | | - ) |
54 | | - |
55 | | - def _update_zones(self) -> None: |
56 | | - """Helper-function for _all_entity_data() and async_update(). |
57 | | -
|
58 | | - Collect data for each zone/location and add to self._zones. |
59 | | - """ |
60 | | - for location_id, zone in self._zones.items(): |
61 | | - data = self._get_location_data(location_id) |
62 | | - zone.update(data) |
63 | | - |
64 | | - def _update_gw_entities(self) -> None: |
65 | | - """Helper-function for _all_entities_data() and async_update(). |
66 | | -
|
67 | | - Collect data for each entity and add to self.gw_entities. |
68 | | - """ |
69 | | - mac_list: list[str] = [] |
70 | | - for entity_id, entity in self.gw_entities.items(): |
71 | | - data = self._get_entity_data(entity_id) |
72 | | - if entity_id == self.gateway_id: |
73 | | - mac_list = self._detect_low_batteries() |
74 | | - self._add_or_update_notifications(entity_id, entity, data) |
75 | | - |
76 | | - entity.update(data) |
77 | | - is_battery_low = ( |
78 | | - mac_list |
79 | | - and "low_battery" in entity["binary_sensors"] |
80 | | - and entity["zigbee_mac_address"] in mac_list |
81 | | - and entity["dev_class"] |
82 | | - in ( |
83 | | - "thermo_sensor", |
84 | | - "thermostatic_radiator_valve", |
85 | | - "zone_thermometer", |
86 | | - "zone_thermostat", |
87 | | - ) |
88 | | - ) |
89 | | - if is_battery_low: |
90 | | - entity["binary_sensors"]["low_battery"] = True |
91 | | - |
92 | | - self._update_for_cooling(entity) |
93 | | - |
94 | | - remove_empty_platform_dicts(entity) |
95 | | - |
96 | | - def _detect_low_batteries(self) -> list[str]: |
97 | | - """Helper-function updating the low-battery binary_sensor status from a Battery-is-low message.""" |
98 | | - mac_address_list: list[str] = [] |
99 | | - mac_pattern = re.compile(r"(?:[0-9A-F]{2}){8}") |
100 | | - matches = ["Battery", "below"] |
101 | | - if self._notifications: |
102 | | - for msg_id, notification in list(self._notifications.items()): |
103 | | - mac_address: str | None = None |
104 | | - message: str | None = notification.get("message") |
105 | | - warning: str | None = notification.get("warning") |
106 | | - notify = message or warning |
107 | | - if ( |
108 | | - notify is not None |
109 | | - and all(x in notify for x in matches) |
110 | | - and (mac_addresses := mac_pattern.findall(notify)) |
111 | | - ): |
112 | | - mac_address = mac_addresses[0] # re.findall() outputs a list |
113 | | - |
114 | | - if mac_address is not None: |
115 | | - mac_address_list.append(mac_address) |
116 | | - if message is not None: # only block message-type notifications |
117 | | - self._notifications.pop(msg_id) |
118 | | - |
119 | | - return mac_address_list |
120 | | - |
121 | | - def _add_or_update_notifications( |
122 | | - self, entity_id: str, entity: GwEntityData, data: GwEntityData |
123 | | - ) -> None: |
124 | | - """Helper-function adding or updating the Plugwise notifications.""" |
125 | | - if ( |
126 | | - entity_id == self.gateway_id |
127 | | - and (self._is_thermostat or self.smile_type == "power") |
128 | | - ) or ( |
129 | | - "binary_sensors" in entity |
130 | | - and "plugwise_notification" in entity["binary_sensors"] |
131 | | - ): |
132 | | - data["binary_sensors"]["plugwise_notification"] = bool(self._notifications) |
133 | | - self._count += 1 |
134 | | - |
135 | | - def _update_for_cooling(self, entity: GwEntityData) -> None: |
136 | | - """Helper-function for adding/updating various cooling-related values.""" |
137 | | - # For Anna and heating + cooling, replace setpoint with setpoint_high/_low |
138 | | - if ( |
139 | | - self.smile(ANNA) |
140 | | - and self._cooling_present |
141 | | - and entity["dev_class"] == "thermostat" |
142 | | - ): |
143 | | - thermostat = entity["thermostat"] |
144 | | - sensors = entity["sensors"] |
145 | | - temp_dict: ActuatorData = { |
146 | | - "setpoint_low": thermostat["setpoint"], |
147 | | - "setpoint_high": MAX_SETPOINT, |
148 | | - } |
149 | | - if self._cooling_enabled: |
150 | | - temp_dict = { |
151 | | - "setpoint_low": MIN_SETPOINT, |
152 | | - "setpoint_high": thermostat["setpoint"], |
153 | | - } |
154 | | - thermostat.pop("setpoint") |
155 | | - temp_dict.update(thermostat) |
156 | | - entity["thermostat"] = temp_dict |
157 | | - if "setpoint" in sensors: |
158 | | - sensors.pop("setpoint") |
159 | | - sensors["setpoint_low"] = temp_dict["setpoint_low"] |
160 | | - sensors["setpoint_high"] = temp_dict["setpoint_high"] |
161 | | - self._count += 2 # add 4, remove 2 |
162 | | - |
163 | | - def _get_location_data(self, loc_id: str) -> GwEntityData: |
164 | | - """Helper-function for _all_entity_data() and async_update(). |
165 | | -
|
166 | | - Provide entity-data, based on Location ID (= loc_id). |
167 | | - """ |
168 | | - zone = self._zones[loc_id] |
169 | | - data = self._get_zone_data(loc_id) |
170 | | - data["control_state"] = "idle" |
171 | | - self._count += 1 |
172 | | - if (ctrl_state := self._control_state(data, loc_id)) and str(ctrl_state) in ( |
173 | | - "cooling", |
174 | | - "heating", |
175 | | - "preheating", |
176 | | - ): |
177 | | - data["control_state"] = str(ctrl_state) |
178 | | - |
179 | | - data["sensors"].pop("setpoint") # remove, only used in _control_state() |
180 | | - self._count -= 1 |
181 | | - |
182 | | - # Thermostat data (presets, temperatures etc) |
183 | | - self._climate_data(loc_id, zone, data) |
184 | | - |
185 | | - return data |
186 | | - |
187 | | - def _get_entity_data(self, entity_id: str) -> GwEntityData: |
188 | | - """Helper-function for _update_gw_entities() and async_update(). |
189 | | -
|
190 | | - Provide entity-data, based on appliance_id (= entity_id). |
191 | | - """ |
192 | | - entity = self.gw_entities[entity_id] |
193 | | - data = self._get_measurement_data(entity_id) |
194 | | - |
195 | | - # Check availability of wired-connected entities |
196 | | - # Smartmeter |
197 | | - self._check_availability( |
198 | | - entity, "smartmeter", data, "P1 does not seem to be connected" |
199 | | - ) |
200 | | - # OpenTherm entity |
201 | | - if entity["name"] != "OnOff": |
202 | | - self._check_availability( |
203 | | - entity, "heater_central", data, "no OpenTherm communication" |
204 | | - ) |
205 | | - |
206 | | - # Switching groups data |
207 | | - self._entity_switching_group(entity, data) |
208 | | - # Adam data |
209 | | - if self.smile(ADAM): |
210 | | - self._get_adam_data(entity, data) |
211 | | - |
212 | | - # Thermostat data for Anna (presets, temperatures etc) |
213 | | - if self.smile(ANNA) and entity["dev_class"] == "thermostat": |
214 | | - self._climate_data(entity_id, entity, data) |
215 | | - self._get_anna_control_state(data) |
216 | | - |
217 | | - return data |
218 | | - |
219 | | - def _check_availability( |
220 | | - self, entity: GwEntityData, dev_class: str, data: GwEntityData, message: str |
221 | | - ) -> None: |
222 | | - """Helper-function for _get_entity_data(). |
223 | | -
|
224 | | - Provide availability status for the wired-connected devices. |
225 | | - """ |
226 | | - if entity["dev_class"] == dev_class: |
227 | | - data["available"] = True |
228 | | - self._count += 1 |
229 | | - for item in self._notifications.values(): |
230 | | - for msg in item.values(): |
231 | | - if message in msg: |
232 | | - data["available"] = False |
233 | | - |
234 | | - def _get_adam_data(self, entity: GwEntityData, data: GwEntityData) -> None: |
235 | | - """Helper-function for _get_entity_data(). |
236 | | -
|
237 | | - Determine Adam heating-status for on-off heating via valves, |
238 | | - available regulations_modes and thermostat control_states, |
239 | | - and add missing cooling_enabled when required. |
240 | | - """ |
241 | | - if entity["dev_class"] == "heater_central": |
242 | | - # Indicate heating_state based on valves being open in case of city-provided heating |
243 | | - if self._on_off_device and isinstance(self._heating_valves(), int): |
244 | | - data["binary_sensors"]["heating_state"] = self._heating_valves() != 0 |
245 | | - # Add cooling_enabled binary_sensor |
246 | | - if "binary_sensors" in data: |
247 | | - if ( |
248 | | - "cooling_enabled" not in data["binary_sensors"] |
249 | | - and self._cooling_present |
250 | | - ): |
251 | | - data["binary_sensors"]["cooling_enabled"] = self._cooling_enabled |
252 | | - |
253 | | - # Show the allowed regulation_modes and gateway_modes |
254 | | - if entity["dev_class"] == "gateway": |
255 | | - if self._reg_allowed_modes: |
256 | | - data["regulation_modes"] = self._reg_allowed_modes |
257 | | - self._count += 1 |
258 | | - if self._gw_allowed_modes: |
259 | | - data["gateway_modes"] = self._gw_allowed_modes |
260 | | - self._count += 1 |
261 | | - |
262 | | - def _climate_data( |
263 | | - self, location_id: str, entity: GwEntityData, data: GwEntityData |
264 | | - ) -> None: |
265 | | - """Helper-function for _get_entity_data(). |
266 | | -
|
267 | | - Determine climate-control entity data. |
268 | | - """ |
269 | | - loc_id = location_id |
270 | | - if entity.get("location") is not None: |
271 | | - loc_id = entity["location"] |
272 | | - |
273 | | - # Presets |
274 | | - data["preset_modes"] = None |
275 | | - data["active_preset"] = None |
276 | | - self._count += 2 |
277 | | - if presets := self._presets(loc_id): |
278 | | - data["preset_modes"] = list(presets) |
279 | | - data["active_preset"] = self._preset(loc_id) |
280 | | - |
281 | | - # Schedule |
282 | | - avail_schedules, sel_schedule = self._schedules(loc_id) |
283 | | - if avail_schedules != [NONE]: |
284 | | - data["available_schedules"] = avail_schedules |
285 | | - data["select_schedule"] = sel_schedule |
286 | | - self._count += 2 |
287 | | - |
288 | | - # Set HA climate HVACMode: auto, heat, heat_cool, cool and off |
289 | | - data["climate_mode"] = "auto" |
290 | | - self._count += 1 |
291 | | - if sel_schedule in (NONE, OFF): |
292 | | - data["climate_mode"] = "heat" |
293 | | - if self._cooling_present: |
294 | | - data["climate_mode"] = ( |
295 | | - "cool" if self.check_reg_mode("cooling") else "heat_cool" |
296 | | - ) |
297 | | - |
298 | | - if self.check_reg_mode("off"): |
299 | | - data["climate_mode"] = "off" |
300 | | - |
301 | | - if NONE not in avail_schedules: |
302 | | - self._get_schedule_states_with_off( |
303 | | - loc_id, avail_schedules, sel_schedule, data |
304 | | - ) |
305 | | - |
306 | | - def check_reg_mode(self, mode: str) -> bool: |
307 | | - """Helper-function for device_data_climate().""" |
308 | | - gateway = self.gw_entities[self.gateway_id] |
309 | | - return ( |
310 | | - "regulation_modes" in gateway and gateway["select_regulation_mode"] == mode |
311 | | - ) |
312 | | - |
313 | | - def _get_anna_control_state(self, data: GwEntityData) -> None: |
314 | | - """Set the thermostat control_state based on the opentherm/onoff device state.""" |
315 | | - data["control_state"] = "idle" |
316 | | - self._count += 1 |
317 | | - for entity in self.gw_entities.values(): |
318 | | - if entity["dev_class"] != "heater_central": |
319 | | - continue |
320 | | - |
321 | | - binary_sensors = entity["binary_sensors"] |
322 | | - if binary_sensors["heating_state"]: |
323 | | - data["control_state"] = "heating" |
324 | | - if binary_sensors.get("cooling_state"): |
325 | | - data["control_state"] = "cooling" |
326 | | - |
327 | | - def _get_schedule_states_with_off( |
328 | | - self, location: str, schedules: list[str], selected: str, data: GwEntityData |
329 | | - ) -> None: |
330 | | - """Collect schedules with states for each thermostat. |
331 | | -
|
332 | | - Also, replace NONE by OFF when none of the schedules are active. |
333 | | - """ |
334 | | - loc_schedule_states: dict[str, str] = {} |
335 | | - for schedule in schedules: |
336 | | - loc_schedule_states[schedule] = "off" |
337 | | - if schedule == selected and data["climate_mode"] == "auto": |
338 | | - loc_schedule_states[schedule] = "on" |
339 | | - self._schedule_old_states[location] = loc_schedule_states |
340 | | - |
341 | | - all_off = True |
342 | | - for state in self._schedule_old_states[location].values(): |
343 | | - if state == "on": |
344 | | - all_off = False |
345 | | - if all_off: |
346 | | - data["select_schedule"] = OFF |
0 commit comments