Home Assistant Unofficial Reference 2024.12.1
cover.py
Go to the documentation of this file.
1 """Support for MQTT cover devices."""
2 
3 from __future__ import annotations
4 
5 from contextlib import suppress
6 import logging
7 from typing import Any
8 
9 import voluptuous as vol
10 
11 from homeassistant.components import cover
13  ATTR_POSITION,
14  ATTR_TILT_POSITION,
15  DEVICE_CLASSES_SCHEMA,
16  CoverEntity,
17  CoverEntityFeature,
18  CoverState,
19 )
20 from homeassistant.config_entries import ConfigEntry
21 from homeassistant.const import (
22  CONF_DEVICE_CLASS,
23  CONF_NAME,
24  CONF_OPTIMISTIC,
25  CONF_VALUE_TEMPLATE,
26  STATE_CLOSED,
27  STATE_CLOSING,
28  STATE_OPEN,
29  STATE_OPENING,
30 )
31 from homeassistant.core import HomeAssistant, callback
33 from homeassistant.helpers.entity_platform import AddEntitiesCallback
34 from homeassistant.helpers.service_info.mqtt import ReceivePayloadType
35 from homeassistant.helpers.typing import ConfigType, VolSchemaType
36 from homeassistant.util.json import JSON_DECODE_EXCEPTIONS, json_loads
38  percentage_to_ranged_value,
39  ranged_value_to_percentage,
40 )
41 
42 from . import subscription
43 from .config import MQTT_BASE_SCHEMA
44 from .const import (
45  CONF_COMMAND_TOPIC,
46  CONF_PAYLOAD_CLOSE,
47  CONF_PAYLOAD_OPEN,
48  CONF_PAYLOAD_STOP,
49  CONF_POSITION_CLOSED,
50  CONF_POSITION_OPEN,
51  CONF_RETAIN,
52  CONF_STATE_CLOSED,
53  CONF_STATE_CLOSING,
54  CONF_STATE_OPEN,
55  CONF_STATE_OPENING,
56  CONF_STATE_TOPIC,
57  DEFAULT_OPTIMISTIC,
58  DEFAULT_PAYLOAD_CLOSE,
59  DEFAULT_PAYLOAD_OPEN,
60  DEFAULT_POSITION_CLOSED,
61  DEFAULT_POSITION_OPEN,
62  DEFAULT_RETAIN,
63  PAYLOAD_NONE,
64 )
65 from .entity import MqttEntity, async_setup_entity_entry_helper
66 from .models import MqttCommandTemplate, MqttValueTemplate, ReceiveMessage
67 from .schemas import MQTT_ENTITY_COMMON_SCHEMA
68 from .util import valid_publish_topic, valid_subscribe_topic
69 
70 _LOGGER = logging.getLogger(__name__)
71 
72 PARALLEL_UPDATES = 0
73 
74 CONF_GET_POSITION_TOPIC = "position_topic"
75 CONF_GET_POSITION_TEMPLATE = "position_template"
76 CONF_SET_POSITION_TOPIC = "set_position_topic"
77 CONF_SET_POSITION_TEMPLATE = "set_position_template"
78 CONF_TILT_COMMAND_TOPIC = "tilt_command_topic"
79 CONF_TILT_COMMAND_TEMPLATE = "tilt_command_template"
80 CONF_TILT_STATUS_TOPIC = "tilt_status_topic"
81 CONF_TILT_STATUS_TEMPLATE = "tilt_status_template"
82 
83 CONF_STATE_STOPPED = "state_stopped"
84 CONF_TILT_CLOSED_POSITION = "tilt_closed_value"
85 CONF_TILT_MAX = "tilt_max"
86 CONF_TILT_MIN = "tilt_min"
87 CONF_TILT_OPEN_POSITION = "tilt_opened_value"
88 CONF_TILT_STATE_OPTIMISTIC = "tilt_optimistic"
89 
90 TILT_PAYLOAD = "tilt"
91 COVER_PAYLOAD = "cover"
92 
93 DEFAULT_NAME = "MQTT Cover"
94 
95 DEFAULT_STATE_STOPPED = "stopped"
96 DEFAULT_PAYLOAD_STOP = "STOP"
97 
98 DEFAULT_TILT_CLOSED_POSITION = 0
99 DEFAULT_TILT_MAX = 100
100 DEFAULT_TILT_MIN = 0
101 DEFAULT_TILT_OPEN_POSITION = 100
102 DEFAULT_TILT_OPTIMISTIC = False
103 
104 TILT_FEATURES = (
105  CoverEntityFeature.OPEN_TILT
106  | CoverEntityFeature.CLOSE_TILT
107  | CoverEntityFeature.STOP_TILT
108  | CoverEntityFeature.SET_TILT_POSITION
109 )
110 
111 MQTT_COVER_ATTRIBUTES_BLOCKED = frozenset(
112  {
113  cover.ATTR_CURRENT_POSITION,
114  cover.ATTR_CURRENT_TILT_POSITION,
115  }
116 )
117 
118 
119 def validate_options(config: ConfigType) -> ConfigType:
120  """Validate options.
121 
122  If set position topic is set then get position topic is set as well.
123  """
124  if CONF_SET_POSITION_TOPIC in config and CONF_GET_POSITION_TOPIC not in config:
125  raise vol.Invalid(
126  f"'{CONF_SET_POSITION_TOPIC}' must be set together with"
127  f" '{CONF_GET_POSITION_TOPIC}'."
128  )
129 
130  # if templates are set make sure the topic for the template is also set
131 
132  if CONF_VALUE_TEMPLATE in config and CONF_STATE_TOPIC not in config:
133  raise vol.Invalid(
134  f"'{CONF_VALUE_TEMPLATE}' must be set together with '{CONF_STATE_TOPIC}'."
135  )
136 
137  if CONF_GET_POSITION_TEMPLATE in config and CONF_GET_POSITION_TOPIC not in config:
138  raise vol.Invalid(
139  f"'{CONF_GET_POSITION_TEMPLATE}' must be set together with"
140  f" '{CONF_GET_POSITION_TOPIC}'."
141  )
142 
143  if CONF_SET_POSITION_TEMPLATE in config and CONF_SET_POSITION_TOPIC not in config:
144  raise vol.Invalid(
145  f"'{CONF_SET_POSITION_TEMPLATE}' must be set together with"
146  f" '{CONF_SET_POSITION_TOPIC}'."
147  )
148 
149  if CONF_TILT_COMMAND_TEMPLATE in config and CONF_TILT_COMMAND_TOPIC not in config:
150  raise vol.Invalid(
151  f"'{CONF_TILT_COMMAND_TEMPLATE}' must be set together with"
152  f" '{CONF_TILT_COMMAND_TOPIC}'."
153  )
154 
155  if CONF_TILT_STATUS_TEMPLATE in config and CONF_TILT_STATUS_TOPIC not in config:
156  raise vol.Invalid(
157  f"'{CONF_TILT_STATUS_TEMPLATE}' must be set together with"
158  f" '{CONF_TILT_STATUS_TOPIC}'."
159  )
160 
161  return config
162 
163 
164 _PLATFORM_SCHEMA_BASE = MQTT_BASE_SCHEMA.extend(
165  {
166  vol.Optional(CONF_COMMAND_TOPIC): valid_publish_topic,
167  vol.Optional(CONF_DEVICE_CLASS): vol.Any(DEVICE_CLASSES_SCHEMA, None),
168  vol.Optional(CONF_GET_POSITION_TOPIC): valid_subscribe_topic,
169  vol.Optional(CONF_NAME): vol.Any(cv.string, None),
170  vol.Optional(CONF_OPTIMISTIC, default=DEFAULT_OPTIMISTIC): cv.boolean,
171  vol.Optional(CONF_PAYLOAD_CLOSE, default=DEFAULT_PAYLOAD_CLOSE): vol.Any(
172  cv.string, None
173  ),
174  vol.Optional(CONF_PAYLOAD_OPEN, default=DEFAULT_PAYLOAD_OPEN): vol.Any(
175  cv.string, None
176  ),
177  vol.Optional(CONF_PAYLOAD_STOP, default=DEFAULT_PAYLOAD_STOP): vol.Any(
178  cv.string, None
179  ),
180  vol.Optional(CONF_POSITION_CLOSED, default=DEFAULT_POSITION_CLOSED): int,
181  vol.Optional(CONF_POSITION_OPEN, default=DEFAULT_POSITION_OPEN): int,
182  vol.Optional(CONF_RETAIN, default=DEFAULT_RETAIN): cv.boolean,
183  vol.Optional(CONF_SET_POSITION_TEMPLATE): cv.template,
184  vol.Optional(CONF_SET_POSITION_TOPIC): valid_publish_topic,
185  vol.Optional(CONF_STATE_CLOSED, default=STATE_CLOSED): cv.string,
186  vol.Optional(CONF_STATE_CLOSING, default=STATE_CLOSING): cv.string,
187  vol.Optional(CONF_STATE_OPEN, default=STATE_OPEN): cv.string,
188  vol.Optional(CONF_STATE_OPENING, default=STATE_OPENING): cv.string,
189  vol.Optional(CONF_STATE_STOPPED, default=DEFAULT_STATE_STOPPED): cv.string,
190  vol.Optional(CONF_STATE_TOPIC): valid_subscribe_topic,
191  vol.Optional(
192  CONF_TILT_CLOSED_POSITION, default=DEFAULT_TILT_CLOSED_POSITION
193  ): int,
194  vol.Optional(CONF_TILT_COMMAND_TOPIC): valid_publish_topic,
195  vol.Optional(CONF_TILT_MAX, default=DEFAULT_TILT_MAX): int,
196  vol.Optional(CONF_TILT_MIN, default=DEFAULT_TILT_MIN): int,
197  vol.Optional(CONF_TILT_OPEN_POSITION, default=DEFAULT_TILT_OPEN_POSITION): int,
198  vol.Optional(
199  CONF_TILT_STATE_OPTIMISTIC, default=DEFAULT_TILT_OPTIMISTIC
200  ): cv.boolean,
201  vol.Optional(CONF_TILT_STATUS_TOPIC): valid_subscribe_topic,
202  vol.Optional(CONF_TILT_STATUS_TEMPLATE): cv.template,
203  vol.Optional(CONF_VALUE_TEMPLATE): cv.template,
204  vol.Optional(CONF_GET_POSITION_TEMPLATE): cv.template,
205  vol.Optional(CONF_TILT_COMMAND_TEMPLATE): cv.template,
206  }
207 ).extend(MQTT_ENTITY_COMMON_SCHEMA.schema)
208 
209 PLATFORM_SCHEMA_MODERN = vol.All(
210  _PLATFORM_SCHEMA_BASE,
211  validate_options,
212 )
213 
214 DISCOVERY_SCHEMA = vol.All(
215  _PLATFORM_SCHEMA_BASE.extend({}, extra=vol.REMOVE_EXTRA),
216  validate_options,
217 )
218 
219 
221  hass: HomeAssistant,
222  config_entry: ConfigEntry,
223  async_add_entities: AddEntitiesCallback,
224 ) -> None:
225  """Set up MQTT cover through YAML and through MQTT discovery."""
227  hass,
228  config_entry,
229  MqttCover,
230  cover.DOMAIN,
231  async_add_entities,
232  DISCOVERY_SCHEMA,
233  PLATFORM_SCHEMA_MODERN,
234  )
235 
236 
238  """Representation of a cover that can be controlled using MQTT."""
239 
240  _attr_is_closed: bool | None = None
241  _attributes_extra_blocked: frozenset[str] = MQTT_COVER_ATTRIBUTES_BLOCKED
242  _default_name = DEFAULT_NAME
243  _entity_id_format: str = cover.ENTITY_ID_FORMAT
244  _optimistic: bool
245  _tilt_optimistic: bool
246  _tilt_closed_percentage: int
247  _tilt_open_percentage: int
248  _pos_range: tuple[int, int]
249  _tilt_range: tuple[int, int]
250 
251  @staticmethod
252  def config_schema() -> VolSchemaType:
253  """Return the config schema."""
254  return DISCOVERY_SCHEMA
255 
256  def _setup_from_config(self, config: ConfigType) -> None:
257  """Set up cover from config."""
258  self._pos_range_pos_range = (config[CONF_POSITION_CLOSED] + 1, config[CONF_POSITION_OPEN])
259  self._tilt_range_tilt_range = (config[CONF_TILT_MIN] + 1, config[CONF_TILT_MAX])
261  self._tilt_range_tilt_range, config[CONF_TILT_CLOSED_POSITION]
262  )
264  self._tilt_range_tilt_range, config[CONF_TILT_OPEN_POSITION]
265  )
266  no_position = (
267  config.get(CONF_SET_POSITION_TOPIC) is None
268  and config.get(CONF_GET_POSITION_TOPIC) is None
269  )
270  no_state = (
271  config.get(CONF_COMMAND_TOPIC) is None
272  and config.get(CONF_STATE_TOPIC) is None
273  )
274  no_tilt = (
275  config.get(CONF_TILT_COMMAND_TOPIC) is None
276  and config.get(CONF_TILT_STATUS_TOPIC) is None
277  )
278  optimistic_position = (
279  config.get(CONF_SET_POSITION_TOPIC) is not None
280  and config.get(CONF_GET_POSITION_TOPIC) is None
281  )
282  optimistic_state = (
283  config.get(CONF_COMMAND_TOPIC) is not None
284  and config.get(CONF_STATE_TOPIC) is None
285  )
286  optimistic_tilt = (
287  config.get(CONF_TILT_COMMAND_TOPIC) is not None
288  and config.get(CONF_TILT_STATUS_TOPIC) is None
289  )
290 
291  self._optimistic_optimistic = config[CONF_OPTIMISTIC] or (
292  (no_position or optimistic_position)
293  and (no_state or optimistic_state)
294  and (no_tilt or optimistic_tilt)
295  )
296  self._attr_assumed_state_attr_assumed_state = self._optimistic_optimistic
297 
298  self._tilt_optimistic_tilt_optimistic = (
299  config[CONF_TILT_STATE_OPTIMISTIC]
300  or config.get(CONF_TILT_STATUS_TOPIC) is None
301  )
302 
303  template_config_attributes = {
304  "position_open": config[CONF_POSITION_OPEN],
305  "position_closed": config[CONF_POSITION_CLOSED],
306  "tilt_min": config[CONF_TILT_MIN],
307  "tilt_max": config[CONF_TILT_MAX],
308  }
309 
310  self._value_template_value_template = MqttValueTemplate(
311  config.get(CONF_VALUE_TEMPLATE), entity=self
312  ).async_render_with_possible_json_value
313 
314  self._set_position_template_set_position_template = MqttCommandTemplate(
315  config.get(CONF_SET_POSITION_TEMPLATE), entity=self
316  ).async_render
317 
318  self._get_position_template_get_position_template = MqttValueTemplate(
319  config.get(CONF_GET_POSITION_TEMPLATE),
320  entity=self,
321  config_attributes=template_config_attributes,
322  ).async_render_with_possible_json_value
323 
324  self._set_tilt_template_set_tilt_template = MqttCommandTemplate(
325  self._config_config.get(CONF_TILT_COMMAND_TEMPLATE), entity=self
326  ).async_render
327 
328  self._tilt_status_template_tilt_status_template = MqttValueTemplate(
329  self._config_config.get(CONF_TILT_STATUS_TEMPLATE),
330  entity=self,
331  config_attributes=template_config_attributes,
332  ).async_render_with_possible_json_value
333 
334  self._attr_device_class_attr_device_class = self._config_config.get(CONF_DEVICE_CLASS)
335 
336  supported_features = CoverEntityFeature(0)
337  if self._config_config.get(CONF_COMMAND_TOPIC) is not None:
338  if self._config_config.get(CONF_PAYLOAD_OPEN) is not None:
339  supported_features |= CoverEntityFeature.OPEN
340  if self._config_config.get(CONF_PAYLOAD_CLOSE) is not None:
341  supported_features |= CoverEntityFeature.CLOSE
342  if self._config_config.get(CONF_PAYLOAD_STOP) is not None:
343  supported_features |= CoverEntityFeature.STOP
344 
345  if self._config_config.get(CONF_SET_POSITION_TOPIC) is not None:
346  supported_features |= CoverEntityFeature.SET_POSITION
347 
348  if self._config_config.get(CONF_TILT_COMMAND_TOPIC) is not None:
349  supported_features |= TILT_FEATURES
350 
351  self._attr_supported_features_attr_supported_features = supported_features
352 
353  @callback
354  def _update_state(self, state: str | None) -> None:
355  """Update the cover state."""
356  if state is None:
357  # Reset the state to `unknown`
358  self._attr_is_closed_attr_is_closed = None
359  else:
360  self._attr_is_closed_attr_is_closed = state == CoverState.CLOSED
361  self._attr_is_opening_attr_is_opening = state == CoverState.OPENING
362  self._attr_is_closing_attr_is_closing = state == CoverState.CLOSING
363 
364  @callback
365  def _tilt_message_received(self, msg: ReceiveMessage) -> None:
366  """Handle tilt updates."""
367  payload = self._tilt_status_template_tilt_status_template(msg.payload)
368 
369  if not payload:
370  _LOGGER.debug("Ignoring empty tilt message from '%s'", msg.topic)
371  return
372 
373  self.tilt_payload_receivedtilt_payload_received(payload)
374 
375  @callback
376  def _state_message_received(self, msg: ReceiveMessage) -> None:
377  """Handle new MQTT state messages."""
378  payload = self._value_template_value_template(msg.payload)
379 
380  if not payload:
381  _LOGGER.debug("Ignoring empty state message from '%s'", msg.topic)
382  return
383 
384  state: str | None
385  if payload == self._config_config[CONF_STATE_STOPPED]:
386  if self._config_config.get(CONF_GET_POSITION_TOPIC) is not None:
387  state = (
388  CoverState.CLOSED
389  if self._attr_current_cover_position_attr_current_cover_position == DEFAULT_POSITION_CLOSED
390  else CoverState.OPEN
391  )
392  else:
393  state = (
394  CoverState.CLOSED
395  if self.statestatestate in [CoverState.CLOSED, CoverState.CLOSING]
396  else CoverState.OPEN
397  )
398  elif payload == self._config_config[CONF_STATE_OPENING]:
399  state = CoverState.OPENING
400  elif payload == self._config_config[CONF_STATE_CLOSING]:
401  state = CoverState.CLOSING
402  elif payload == self._config_config[CONF_STATE_OPEN]:
403  state = CoverState.OPEN
404  elif payload == self._config_config[CONF_STATE_CLOSED]:
405  state = CoverState.CLOSED
406  elif payload == PAYLOAD_NONE:
407  state = None
408  else:
409  _LOGGER.warning(
410  (
411  "Payload is not supported (e.g. open, closed, opening, closing,"
412  " stopped): %s"
413  ),
414  payload,
415  )
416  return
417  self._update_state_update_state(state)
418 
419  @callback
420  def _position_message_received(self, msg: ReceiveMessage) -> None:
421  """Handle new MQTT position messages."""
422  payload: ReceivePayloadType = self._get_position_template_get_position_template(msg.payload)
423  payload_dict: Any = None
424 
425  if not payload:
426  _LOGGER.debug("Ignoring empty position message from '%s'", msg.topic)
427  return
428 
429  with suppress(*JSON_DECODE_EXCEPTIONS):
430  payload_dict = json_loads(payload)
431 
432  if payload_dict and isinstance(payload_dict, dict):
433  if "position" not in payload_dict:
434  _LOGGER.warning(
435  "Template (position_template) returned JSON without position"
436  " attribute"
437  )
438  return
439  if "tilt_position" in payload_dict:
440  if not self._config_config.get(CONF_TILT_STATE_OPTIMISTIC):
441  # reset forced set tilt optimistic
442  self._tilt_optimistic_tilt_optimistic = False
443  self.tilt_payload_receivedtilt_payload_received(payload_dict["tilt_position"])
444  payload = payload_dict["position"]
445 
446  try:
447  percentage_payload = ranged_value_to_percentage(
448  self._pos_range_pos_range, float(payload)
449  )
450  except ValueError:
451  _LOGGER.warning("Payload '%s' is not numeric", payload)
452  return
453 
454  self._attr_current_cover_position_attr_current_cover_position = min(100, max(0, percentage_payload))
455  if self._config_config.get(CONF_STATE_TOPIC) is None:
456  self._update_state_update_state(
457  CoverState.CLOSED
459  else CoverState.OPEN
460  )
461 
462  @callback
463  def _prepare_subscribe_topics(self) -> None:
464  """(Re)Subscribe to topics."""
465  self.add_subscriptionadd_subscription(
466  CONF_GET_POSITION_TOPIC,
467  self._position_message_received_position_message_received,
468  {
469  "_attr_current_cover_position",
470  "_attr_current_cover_tilt_position",
471  "_attr_is_closed",
472  "_attr_is_closing",
473  "_attr_is_opening",
474  },
475  )
476  self.add_subscriptionadd_subscription(
477  CONF_STATE_TOPIC,
478  self._state_message_received_state_message_received,
479  {"_attr_is_closed", "_attr_is_closing", "_attr_is_opening"},
480  )
481  self.add_subscriptionadd_subscription(
482  CONF_TILT_STATUS_TOPIC,
483  self._tilt_message_received_tilt_message_received,
484  {"_attr_current_cover_tilt_position"},
485  )
486 
487  async def _subscribe_topics(self) -> None:
488  """(Re)Subscribe to topics."""
489  subscription.async_subscribe_topics_internal(self.hasshasshass, self._sub_state_sub_state)
490 
491  async def async_open_cover(self, **kwargs: Any) -> None:
492  """Move the cover up.
493 
494  This method is a coroutine.
495  """
496  await self.async_publish_with_configasync_publish_with_config(
497  self._config_config[CONF_COMMAND_TOPIC], self._config_config[CONF_PAYLOAD_OPEN]
498  )
499  if self._optimistic_optimistic:
500  # Optimistically assume that cover has changed state.
501  self._update_state_update_state(CoverState.OPEN)
502  if self._config_config.get(CONF_GET_POSITION_TOPIC):
503  self._attr_current_cover_position_attr_current_cover_position = 100
504  self.async_write_ha_stateasync_write_ha_state()
505 
506  async def async_close_cover(self, **kwargs: Any) -> None:
507  """Move the cover down.
508 
509  This method is a coroutine.
510  """
511  await self.async_publish_with_configasync_publish_with_config(
512  self._config_config[CONF_COMMAND_TOPIC], self._config_config[CONF_PAYLOAD_CLOSE]
513  )
514  if self._optimistic_optimistic:
515  # Optimistically assume that cover has changed state.
516  self._update_state_update_state(CoverState.CLOSED)
517  if self._config_config.get(CONF_GET_POSITION_TOPIC):
518  self._attr_current_cover_position_attr_current_cover_position = 0
519  self.async_write_ha_stateasync_write_ha_state()
520 
521  async def async_stop_cover(self, **kwargs: Any) -> None:
522  """Stop the device.
523 
524  This method is a coroutine.
525  """
526  await self.async_publish_with_configasync_publish_with_config(
527  self._config_config[CONF_COMMAND_TOPIC], self._config_config[CONF_PAYLOAD_STOP]
528  )
529 
530  async def async_open_cover_tilt(self, **kwargs: Any) -> None:
531  """Tilt the cover open."""
532  tilt_open_position = self._config_config[CONF_TILT_OPEN_POSITION]
533  variables = {
534  "tilt_position": tilt_open_position,
535  "entity_id": self.entity_identity_id,
536  "position_open": self._config_config.get(CONF_POSITION_OPEN),
537  "position_closed": self._config_config.get(CONF_POSITION_CLOSED),
538  "tilt_min": self._config_config.get(CONF_TILT_MIN),
539  "tilt_max": self._config_config.get(CONF_TILT_MAX),
540  }
541  tilt_payload = self._set_tilt_template_set_tilt_template(tilt_open_position, variables=variables)
542  await self.async_publish_with_configasync_publish_with_config(
543  self._config_config[CONF_TILT_COMMAND_TOPIC], tilt_payload
544  )
545  if self._tilt_optimistic_tilt_optimistic:
546  self._attr_current_cover_tilt_position_attr_current_cover_tilt_position = self._tilt_open_percentage_tilt_open_percentage
547  self.async_write_ha_stateasync_write_ha_state()
548 
549  async def async_close_cover_tilt(self, **kwargs: Any) -> None:
550  """Tilt the cover closed."""
551  tilt_closed_position = self._config_config[CONF_TILT_CLOSED_POSITION]
552  variables = {
553  "tilt_position": tilt_closed_position,
554  "entity_id": self.entity_identity_id,
555  "position_open": self._config_config.get(CONF_POSITION_OPEN),
556  "position_closed": self._config_config.get(CONF_POSITION_CLOSED),
557  "tilt_min": self._config_config.get(CONF_TILT_MIN),
558  "tilt_max": self._config_config.get(CONF_TILT_MAX),
559  }
560  tilt_payload = self._set_tilt_template_set_tilt_template(
561  tilt_closed_position, variables=variables
562  )
563  await self.async_publish_with_configasync_publish_with_config(
564  self._config_config[CONF_TILT_COMMAND_TOPIC], tilt_payload
565  )
566  if self._tilt_optimistic_tilt_optimistic:
567  self._attr_current_cover_tilt_position_attr_current_cover_tilt_position = self._tilt_closed_percentage_tilt_closed_percentage
568  self.async_write_ha_stateasync_write_ha_state()
569 
570  async def async_set_cover_tilt_position(self, **kwargs: Any) -> None:
571  """Move the cover tilt to a specific position."""
572  tilt_percentage = kwargs[ATTR_TILT_POSITION]
573  tilt_ranged = round(
574  percentage_to_ranged_value(self._tilt_range_tilt_range, tilt_percentage)
575  )
576  # Handover the tilt after calculated from percent would make it more
577  # consistent with receiving templates
578  variables = {
579  "tilt_position": tilt_percentage,
580  "entity_id": self.entity_identity_id,
581  "position_open": self._config_config.get(CONF_POSITION_OPEN),
582  "position_closed": self._config_config.get(CONF_POSITION_CLOSED),
583  "tilt_min": self._config_config.get(CONF_TILT_MIN),
584  "tilt_max": self._config_config.get(CONF_TILT_MAX),
585  }
586  tilt_rendered = self._set_tilt_template_set_tilt_template(tilt_ranged, variables=variables)
587  await self.async_publish_with_configasync_publish_with_config(
588  self._config_config[CONF_TILT_COMMAND_TOPIC], tilt_rendered
589  )
590  if self._tilt_optimistic_tilt_optimistic:
591  _LOGGER.debug("Set tilt value optimistic")
592  self._attr_current_cover_tilt_position_attr_current_cover_tilt_position = tilt_percentage
593  self.async_write_ha_stateasync_write_ha_state()
594 
595  async def async_set_cover_position(self, **kwargs: Any) -> None:
596  """Move the cover to a specific position."""
597  position_percentage = kwargs[ATTR_POSITION]
598  position_ranged = round(
599  percentage_to_ranged_value(self._pos_range_pos_range, position_percentage)
600  )
601  variables = {
602  "position": position_percentage,
603  "entity_id": self.entity_identity_id,
604  "position_open": self._config_config[CONF_POSITION_OPEN],
605  "position_closed": self._config_config[CONF_POSITION_CLOSED],
606  "tilt_min": self._config_config[CONF_TILT_MIN],
607  "tilt_max": self._config_config[CONF_TILT_MAX],
608  }
609  position_rendered = self._set_position_template_set_position_template(
610  position_ranged, variables=variables
611  )
612  await self.async_publish_with_configasync_publish_with_config(
613  self._config_config[CONF_SET_POSITION_TOPIC], position_rendered
614  )
615  if self._optimistic_optimistic:
616  self._update_state_update_state(
617  CoverState.CLOSED
618  if position_percentage <= self._config_config[CONF_POSITION_CLOSED]
619  else CoverState.OPEN
620  )
621  self._attr_current_cover_position_attr_current_cover_position = position_percentage
622  self.async_write_ha_stateasync_write_ha_state()
623 
624  async def async_toggle_tilt(self, **kwargs: Any) -> None:
625  """Toggle the entity."""
626  if (
627  self.current_cover_tilt_positioncurrent_cover_tilt_positioncurrent_cover_tilt_position is not None
628  and self.current_cover_tilt_positioncurrent_cover_tilt_positioncurrent_cover_tilt_position <= self._tilt_closed_percentage_tilt_closed_percentage
629  ):
630  await self.async_open_cover_tiltasync_open_cover_tiltasync_open_cover_tilt(**kwargs)
631  else:
632  await self.async_close_cover_tiltasync_close_cover_tiltasync_close_cover_tilt(**kwargs)
633 
634  @callback
635  def tilt_payload_received(self, _payload: Any) -> None:
636  """Set the tilt value."""
637 
638  try:
639  payload = round(float(_payload))
640  except ValueError:
641  _LOGGER.warning("Payload '%s' is not numeric", _payload)
642  return
643 
644  if (
645  self._config_config[CONF_TILT_MIN] <= payload <= self._config_config[CONF_TILT_MAX]
646  or self._config_config[CONF_TILT_MAX] <= payload <= self._config_config[CONF_TILT_MIN]
647  ):
648  level = ranged_value_to_percentage(self._tilt_range_tilt_range, payload)
649  self._attr_current_cover_tilt_position_attr_current_cover_tilt_position = level
650  else:
651  _LOGGER.warning(
652  "Payload '%s' is out of range, must be between '%s' and '%s' inclusive",
653  payload,
654  self._config_config[CONF_TILT_MIN],
655  self._config_config[CONF_TILT_MAX],
656  )
None async_open_cover_tilt(self, **Any kwargs)
Definition: __init__.py:445
None async_close_cover_tilt(self, **Any kwargs)
Definition: __init__.py:454
None tilt_payload_received(self, Any _payload)
Definition: cover.py:635
None _position_message_received(self, ReceiveMessage msg)
Definition: cover.py:420
None async_close_cover(self, **Any kwargs)
Definition: cover.py:506
None _update_state(self, str|None state)
Definition: cover.py:354
None async_toggle_tilt(self, **Any kwargs)
Definition: cover.py:624
None async_close_cover_tilt(self, **Any kwargs)
Definition: cover.py:549
None async_open_cover(self, **Any kwargs)
Definition: cover.py:491
None async_stop_cover(self, **Any kwargs)
Definition: cover.py:521
None _tilt_message_received(self, ReceiveMessage msg)
Definition: cover.py:365
None _setup_from_config(self, ConfigType config)
Definition: cover.py:256
None async_open_cover_tilt(self, **Any kwargs)
Definition: cover.py:530
None async_set_cover_tilt_position(self, **Any kwargs)
Definition: cover.py:570
None async_set_cover_position(self, **Any kwargs)
Definition: cover.py:595
None _state_message_received(self, ReceiveMessage msg)
Definition: cover.py:376
None async_publish_with_config(self, str topic, PublishPayloadType payload)
Definition: entity.py:1377
bool add_subscription(self, str state_topic_config_key, Callable[[ReceiveMessage], None] msg_callback, set[str]|None tracked_attributes, bool disable_encoding=False)
Definition: entity.py:1484
web.Response get(self, web.Request request, str config_key)
Definition: view.py:88
None async_setup_entry(HomeAssistant hass, ConfigEntry config_entry, AddEntitiesCallback async_add_entities)
Definition: cover.py:224
ConfigType validate_options(ConfigType config)
Definition: cover.py:119
None async_setup_entity_entry_helper(HomeAssistant hass, ConfigEntry entry, type[MqttEntity]|None entity_class, str domain, AddEntitiesCallback async_add_entities, VolSchemaType discovery_schema, VolSchemaType platform_schema_modern, dict[str, type[MqttEntity]]|None schema_class_mapping=None)
Definition: entity.py:245
float percentage_to_ranged_value(tuple[float, float] low_high_range, float percentage)
Definition: percentage.py:81
int ranged_value_to_percentage(tuple[float, float] low_high_range, float value)
Definition: percentage.py:64