Home Assistant Unofficial Reference 2024.12.1
atlantic_pass_apc_zone_control_zone.py
Go to the documentation of this file.
1 """Support for Atlantic Pass APC Heating Control."""
2 
3 from __future__ import annotations
4 
5 from asyncio import sleep
6 from typing import Any, cast
7 
8 from propcache import cached_property
9 from pyoverkiz.enums import OverkizCommand, OverkizCommandParam, OverkizState
10 
12  ATTR_TARGET_TEMP_HIGH,
13  ATTR_TARGET_TEMP_LOW,
14  PRESET_NONE,
15  ClimateEntityFeature,
16  HVACAction,
17  HVACMode,
18 )
19 from homeassistant.const import ATTR_TEMPERATURE, PRECISION_HALVES
20 
21 from ..coordinator import OverkizDataUpdateCoordinator
22 from ..executor import OverkizExecutor
23 from .atlantic_pass_apc_heating_zone import AtlanticPassAPCHeatingZone
24 
25 PRESET_SCHEDULE = "schedule"
26 PRESET_MANUAL = "manual"
27 
28 OVERKIZ_MODE_TO_PRESET_MODES: dict[str, str] = {
29  OverkizCommandParam.MANU: PRESET_MANUAL,
30  OverkizCommandParam.INTERNAL_SCHEDULING: PRESET_SCHEDULE,
31 }
32 
33 PRESET_MODES_TO_OVERKIZ = {v: k for k, v in OVERKIZ_MODE_TO_PRESET_MODES.items()}
34 
35 # Maps the HVAC current ZoneControl system operating mode.
36 OVERKIZ_TO_HVAC_ACTION: dict[str, HVACAction] = {
37  OverkizCommandParam.COOLING: HVACAction.COOLING,
38  OverkizCommandParam.DRYING: HVACAction.DRYING,
39  OverkizCommandParam.HEATING: HVACAction.HEATING,
40  # There is no known way to differentiate OFF from Idle.
41  OverkizCommandParam.STOP: HVACAction.OFF,
42 }
43 
44 HVAC_ACTION_TO_OVERKIZ_PROFILE_STATE: dict[HVACAction, OverkizState] = {
45  HVACAction.COOLING: OverkizState.IO_PASS_APC_COOLING_PROFILE,
46  HVACAction.HEATING: OverkizState.IO_PASS_APC_HEATING_PROFILE,
47 }
48 
49 HVAC_ACTION_TO_OVERKIZ_MODE_STATE: dict[HVACAction, OverkizState] = {
50  HVACAction.COOLING: OverkizState.IO_PASS_APC_COOLING_MODE,
51  HVACAction.HEATING: OverkizState.IO_PASS_APC_HEATING_MODE,
52 }
53 
54 TEMPERATURE_ZONECONTROL_DEVICE_INDEX = 1
55 
56 SUPPORTED_FEATURES: ClimateEntityFeature = (
57  ClimateEntityFeature.PRESET_MODE
58  | ClimateEntityFeature.TURN_OFF
59  | ClimateEntityFeature.TURN_ON
60 )
61 
62 OVERKIZ_THERMAL_CONFIGURATION_TO_HVAC_MODE: dict[
63  OverkizCommandParam, tuple[HVACMode, ClimateEntityFeature]
64 ] = {
65  OverkizCommandParam.COOLING: (
66  HVACMode.COOL,
67  SUPPORTED_FEATURES | ClimateEntityFeature.TARGET_TEMPERATURE,
68  ),
69  OverkizCommandParam.HEATING: (
70  HVACMode.HEAT,
71  SUPPORTED_FEATURES | ClimateEntityFeature.TARGET_TEMPERATURE,
72  ),
73  OverkizCommandParam.HEATING_AND_COOLING: (
74  HVACMode.HEAT_COOL,
75  SUPPORTED_FEATURES | ClimateEntityFeature.TARGET_TEMPERATURE_RANGE,
76  ),
77 }
78 
79 
80 # Those device depends on a main probe that choose the operating mode (heating, cooling, ...).
82  """Representation of Atlantic Pass APC Heating And Cooling Zone Control."""
83 
84  _attr_target_temperature_step = PRECISION_HALVES
85 
86  def __init__(
87  self, device_url: str, coordinator: OverkizDataUpdateCoordinator
88  ) -> None:
89  """Init method."""
90  super().__init__(device_url, coordinator)
91 
92  # When using derogated temperature, we fallback to legacy behavior.
93  if self.is_using_derogated_temperature_fallbackis_using_derogated_temperature_fallback:
94  return
95 
96  self._attr_hvac_modes_attr_hvac_modes_attr_hvac_modes = []
98 
99  # Modes depends on device capabilities.
100  if (thermal_configuration := self.thermal_configurationthermal_configuration) is not None:
101  (
102  device_hvac_mode,
103  climate_entity_feature,
104  ) = thermal_configuration
105  self._attr_hvac_modes_attr_hvac_modes_attr_hvac_modes = [device_hvac_mode, HVACMode.OFF]
106  self._attr_supported_features_attr_supported_features_attr_supported_features = climate_entity_feature
107 
108  # Those are available and tested presets on Shogun.
109  self._attr_preset_modes_attr_preset_modes_attr_preset_modes = [*PRESET_MODES_TO_OVERKIZ]
110 
111  # Those APC Heating and Cooling probes depends on the zone control device (main probe).
112  # Only the base device (#1) can be used to get/set some states.
113  # Like to retrieve and set the current operating mode (heating, cooling, drying, off).
114 
115  self.zone_control_executorzone_control_executor: OverkizExecutor | None = None
116 
117  if (
118  zone_control_device := self.executorexecutor.linked_device(
119  TEMPERATURE_ZONECONTROL_DEVICE_INDEX
120  )
121  ) is not None:
122  self.zone_control_executorzone_control_executor = OverkizExecutor(
123  zone_control_device.device_url,
124  coordinator,
125  )
126 
127  @cached_property
128  def thermal_configuration(self) -> tuple[HVACMode, ClimateEntityFeature] | None:
129  """Retrieve thermal configuration for this devices."""
130 
131  if (
132  (
133  state_thermal_configuration := cast(
134  OverkizCommandParam | None,
135  self.executorexecutor.select_state(OverkizState.CORE_THERMAL_CONFIGURATION),
136  )
137  )
138  is not None
139  and state_thermal_configuration
140  in OVERKIZ_THERMAL_CONFIGURATION_TO_HVAC_MODE
141  ):
142  return OVERKIZ_THERMAL_CONFIGURATION_TO_HVAC_MODE[
143  state_thermal_configuration
144  ]
145 
146  return None
147 
148  @cached_property
149  def device_hvac_mode(self) -> HVACMode | None:
150  """ZoneControlZone device has a single possible mode."""
151 
152  return (
153  None
154  if self.thermal_configurationthermal_configuration is None
155  else self.thermal_configurationthermal_configuration[0]
156  )
157 
158  @property
160  """Check if the device behave like the Pass APC Heating Zone."""
161 
162  return self.executorexecutor.has_command(
163  OverkizCommand.SET_DEROGATED_TARGET_TEMPERATURE
164  )
165 
166  @property
167  def zone_control_hvac_action(self) -> HVACAction:
168  """Return hvac operation ie. heat, cool, dry, off mode."""
169 
170  if self.zone_control_executorzone_control_executor is not None and (
171  (
172  state := self.zone_control_executorzone_control_executor.select_state(
173  OverkizState.IO_PASS_APC_OPERATING_MODE
174  )
175  )
176  is not None
177  ):
178  return OVERKIZ_TO_HVAC_ACTION[cast(str, state)]
179 
180  return HVACAction.OFF
181 
182  @property
183  def hvac_action(self) -> HVACAction | None:
184  """Return the current running hvac operation."""
185 
186  # When ZoneControl action is heating/cooling but Zone is stopped, means the zone is idle.
187  if (
188  hvac_action := self.zone_control_hvac_actionzone_control_hvac_action
189  ) in HVAC_ACTION_TO_OVERKIZ_PROFILE_STATE and cast(
190  str,
191  self.executorexecutor.select_state(
192  HVAC_ACTION_TO_OVERKIZ_PROFILE_STATE[hvac_action]
193  ),
194  ) == OverkizCommandParam.STOP:
195  return HVACAction.IDLE
196 
197  return hvac_action
198 
199  @property
200  def hvac_mode(self) -> HVACMode:
201  """Return hvac operation ie. heat, cool, dry, off mode."""
202 
203  if self.is_using_derogated_temperature_fallbackis_using_derogated_temperature_fallback:
204  return super().hvac_mode
205 
206  if (device_hvac_mode := self.device_hvac_modedevice_hvac_mode) is None:
207  return HVACMode.OFF
208 
209  cooling_is_off = cast(
210  str,
211  self.executorexecutor.select_state(OverkizState.CORE_COOLING_ON_OFF),
212  ) in (OverkizCommandParam.OFF, None)
213 
214  heating_is_off = cast(
215  str,
216  self.executorexecutor.select_state(OverkizState.CORE_HEATING_ON_OFF),
217  ) in (OverkizCommandParam.OFF, None)
218 
219  # Device is Stopped, it means the air flux is flowing but its venting door is closed.
220  if (
221  (device_hvac_mode == HVACMode.COOL and cooling_is_off)
222  or (device_hvac_mode == HVACMode.HEAT and heating_is_off)
223  or (
224  device_hvac_mode == HVACMode.HEAT_COOL
225  and cooling_is_off
226  and heating_is_off
227  )
228  ):
229  return HVACMode.OFF
230 
231  return device_hvac_mode
232 
233  async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
234  """Set new target hvac mode."""
235 
236  if self.is_using_derogated_temperature_fallbackis_using_derogated_temperature_fallback:
237  await super().async_set_hvac_mode(hvac_mode)
238  return
239 
240  # They are mainly managed by the Zone Control device
241  # However, it make sense to map the OFF Mode to the Overkiz STOP Preset
242 
243  on_off_target_command_param = (
244  OverkizCommandParam.OFF
245  if hvac_mode == HVACMode.OFF
246  else OverkizCommandParam.ON
247  )
248 
249  await self.executorexecutor.async_execute_command(
250  OverkizCommand.SET_COOLING_ON_OFF,
251  on_off_target_command_param,
252  )
253  await self.executorexecutor.async_execute_command(
254  OverkizCommand.SET_HEATING_ON_OFF,
255  on_off_target_command_param,
256  )
257 
258  await self.async_refresh_modesasync_refresh_modes()
259 
260  @property
261  def preset_mode(self) -> str | None:
262  """Return the current preset mode, e.g., schedule, manual."""
263 
264  if self.is_using_derogated_temperature_fallbackis_using_derogated_temperature_fallback:
265  return super().preset_mode
266 
267  if (
268  self.zone_control_hvac_actionzone_control_hvac_action in HVAC_ACTION_TO_OVERKIZ_MODE_STATE
269  and (
270  mode_state := HVAC_ACTION_TO_OVERKIZ_MODE_STATE[
271  self.zone_control_hvac_actionzone_control_hvac_action
272  ]
273  )
274  and (
275  (
276  mode := OVERKIZ_MODE_TO_PRESET_MODES[
277  cast(str, self.executorexecutor.select_state(mode_state))
278  ]
279  )
280  is not None
281  )
282  ):
283  return mode
284 
285  return PRESET_NONE
286 
287  async def async_set_preset_mode(self, preset_mode: str) -> None:
288  """Set new preset mode."""
289 
290  if self.is_using_derogated_temperature_fallbackis_using_derogated_temperature_fallback:
291  await super().async_set_preset_mode(preset_mode)
292  return
293 
294  mode = PRESET_MODES_TO_OVERKIZ[preset_mode]
295 
296  # For consistency, it is better both are synced like on the Thermostat.
297  await self.executorexecutor.async_execute_command(
298  OverkizCommand.SET_PASS_APC_HEATING_MODE, mode
299  )
300  await self.executorexecutor.async_execute_command(
301  OverkizCommand.SET_PASS_APC_COOLING_MODE, mode
302  )
303 
304  await self.async_refresh_modesasync_refresh_modes()
305 
306  @property
307  def target_temperature(self) -> float | None:
308  """Return hvac target temperature."""
309 
310  if self.is_using_derogated_temperature_fallbackis_using_derogated_temperature_fallback:
311  return super().target_temperature
312 
313  device_hvac_mode = self.device_hvac_modedevice_hvac_mode
314 
315  if device_hvac_mode == HVACMode.HEAT_COOL:
316  return None
317 
318  if device_hvac_mode == HVACMode.COOL:
319  return cast(
320  float,
321  self.executorexecutor.select_state(
322  OverkizState.CORE_COOLING_TARGET_TEMPERATURE
323  ),
324  )
325 
326  if device_hvac_mode == HVACMode.HEAT:
327  return cast(
328  float,
329  self.executorexecutor.select_state(
330  OverkizState.CORE_HEATING_TARGET_TEMPERATURE
331  ),
332  )
333 
334  return cast(
335  float, self.executorexecutor.select_state(OverkizState.CORE_TARGET_TEMPERATURE)
336  )
337 
338  @property
339  def target_temperature_high(self) -> float | None:
340  """Return the highbound target temperature we try to reach (cooling)."""
341 
342  if self.device_hvac_modedevice_hvac_mode != HVACMode.HEAT_COOL:
343  return None
344 
345  return cast(
346  float,
347  self.executorexecutor.select_state(OverkizState.CORE_COOLING_TARGET_TEMPERATURE),
348  )
349 
350  @property
351  def target_temperature_low(self) -> float | None:
352  """Return the lowbound target temperature we try to reach (heating)."""
353 
354  if self.device_hvac_modedevice_hvac_mode != HVACMode.HEAT_COOL:
355  return None
356 
357  return cast(
358  float,
359  self.executorexecutor.select_state(OverkizState.CORE_HEATING_TARGET_TEMPERATURE),
360  )
361 
362  async def async_set_temperature(self, **kwargs: Any) -> None:
363  """Set new temperature."""
364 
365  if self.is_using_derogated_temperature_fallbackis_using_derogated_temperature_fallback:
366  await super().async_set_temperature(**kwargs)
367  return
368 
369  target_temperature = kwargs.get(ATTR_TEMPERATURE)
370  target_temp_low = kwargs.get(ATTR_TARGET_TEMP_LOW)
371  target_temp_high = kwargs.get(ATTR_TARGET_TEMP_HIGH)
372  hvac_mode = self.hvac_modehvac_modehvac_modehvac_modehvac_modehvac_mode
373 
374  if hvac_mode == HVACMode.HEAT_COOL:
375  if target_temp_low is not None:
376  await self.executorexecutor.async_execute_command(
377  OverkizCommand.SET_HEATING_TARGET_TEMPERATURE,
378  target_temp_low,
379  )
380 
381  if target_temp_high is not None:
382  await self.executorexecutor.async_execute_command(
383  OverkizCommand.SET_COOLING_TARGET_TEMPERATURE,
384  target_temp_high,
385  )
386 
387  elif target_temperature is not None:
388  if hvac_mode == HVACMode.HEAT:
389  await self.executorexecutor.async_execute_command(
390  OverkizCommand.SET_HEATING_TARGET_TEMPERATURE,
391  target_temperature,
392  )
393 
394  elif hvac_mode == HVACMode.COOL:
395  await self.executorexecutor.async_execute_command(
396  OverkizCommand.SET_COOLING_TARGET_TEMPERATURE,
397  target_temperature,
398  )
399 
400  await self.executorexecutor.async_execute_command(
401  OverkizCommand.SET_DEROGATION_ON_OFF_STATE,
402  OverkizCommandParam.ON,
403  )
404 
405  await self.async_refresh_modesasync_refresh_modes()
406 
407  async def async_refresh_modes(self) -> None:
408  """Refresh the device modes to have new states."""
409 
410  # The device needs a bit of time to update everything before a refresh.
411  await sleep(2)
412 
413  await self.executorexecutor.async_execute_command(
414  OverkizCommand.REFRESH_PASS_APC_HEATING_MODE
415  )
416 
417  await self.executorexecutor.async_execute_command(
418  OverkizCommand.REFRESH_PASS_APC_HEATING_PROFILE
419  )
420 
421  await self.executorexecutor.async_execute_command(
422  OverkizCommand.REFRESH_PASS_APC_COOLING_MODE
423  )
424 
425  await self.executorexecutor.async_execute_command(
426  OverkizCommand.REFRESH_PASS_APC_COOLING_PROFILE
427  )
428 
429  await self.executorexecutor.async_execute_command(
430  OverkizCommand.REFRESH_TARGET_TEMPERATURE
431  )
432 
433  @property
434  def min_temp(self) -> float:
435  """Return Minimum Temperature for AC of this group."""
436 
437  device_hvac_mode = self.device_hvac_modedevice_hvac_mode
438 
439  if device_hvac_mode in (HVACMode.HEAT, HVACMode.HEAT_COOL):
440  return cast(
441  float,
442  self.executorexecutor.select_state(
443  OverkizState.CORE_MINIMUM_HEATING_TARGET_TEMPERATURE
444  ),
445  )
446 
447  if device_hvac_mode == HVACMode.COOL:
448  return cast(
449  float,
450  self.executorexecutor.select_state(
451  OverkizState.CORE_MINIMUM_COOLING_TARGET_TEMPERATURE
452  ),
453  )
454 
455  return super().min_temp
456 
457  @property
458  def max_temp(self) -> float:
459  """Return Max Temperature for AC of this group."""
460 
461  device_hvac_mode = self.device_hvac_modedevice_hvac_mode
462 
463  if device_hvac_mode == HVACMode.HEAT:
464  return cast(
465  float,
466  self.executorexecutor.select_state(
467  OverkizState.CORE_MAXIMUM_HEATING_TARGET_TEMPERATURE
468  ),
469  )
470 
471  if device_hvac_mode in (HVACMode.COOL, HVACMode.HEAT_COOL):
472  return cast(
473  float,
474  self.executorexecutor.select_state(
475  OverkizState.CORE_MAXIMUM_COOLING_TARGET_TEMPERATURE
476  ),
477  )
478 
479  return super().max_temp