Home Assistant Unofficial Reference 2024.12.1
binary_sensor.py
Go to the documentation of this file.
1 """Support for MQTT binary sensors."""
2 
3 from __future__ import annotations
4 
5 from datetime import datetime, timedelta
6 import logging
7 from typing import Any
8 
9 import voluptuous as vol
10 
11 from homeassistant.components import binary_sensor
13  DEVICE_CLASSES_SCHEMA,
14  BinarySensorEntity,
15 )
16 from homeassistant.config_entries import ConfigEntry
17 from homeassistant.const import (
18  CONF_DEVICE_CLASS,
19  CONF_FORCE_UPDATE,
20  CONF_NAME,
21  CONF_PAYLOAD_OFF,
22  CONF_PAYLOAD_ON,
23  CONF_VALUE_TEMPLATE,
24  STATE_ON,
25  STATE_UNAVAILABLE,
26  STATE_UNKNOWN,
27 )
28 from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback
30 from homeassistant.helpers.entity_platform import AddEntitiesCallback
31 import homeassistant.helpers.event as evt
32 from homeassistant.helpers.event import async_call_later
33 from homeassistant.helpers.restore_state import RestoreEntity
34 from homeassistant.helpers.typing import ConfigType
35 from homeassistant.util import dt as dt_util
36 
37 from . import subscription
38 from .config import MQTT_RO_SCHEMA
39 from .const import CONF_STATE_TOPIC, PAYLOAD_NONE
40 from .entity import MqttAvailabilityMixin, MqttEntity, async_setup_entity_entry_helper
41 from .models import MqttValueTemplate, ReceiveMessage
42 from .schemas import MQTT_ENTITY_COMMON_SCHEMA
43 
44 _LOGGER = logging.getLogger(__name__)
45 
46 PARALLEL_UPDATES = 0
47 
48 DEFAULT_NAME = "MQTT Binary sensor"
49 CONF_OFF_DELAY = "off_delay"
50 DEFAULT_PAYLOAD_OFF = "OFF"
51 DEFAULT_PAYLOAD_ON = "ON"
52 DEFAULT_FORCE_UPDATE = False
53 CONF_EXPIRE_AFTER = "expire_after"
54 
55 PLATFORM_SCHEMA_MODERN = MQTT_RO_SCHEMA.extend(
56  {
57  vol.Optional(CONF_DEVICE_CLASS): vol.Any(DEVICE_CLASSES_SCHEMA, None),
58  vol.Optional(CONF_EXPIRE_AFTER): cv.positive_int,
59  vol.Optional(CONF_FORCE_UPDATE, default=DEFAULT_FORCE_UPDATE): cv.boolean,
60  vol.Optional(CONF_NAME): vol.Any(cv.string, None),
61  vol.Optional(CONF_OFF_DELAY): cv.positive_int,
62  vol.Optional(CONF_PAYLOAD_OFF, default=DEFAULT_PAYLOAD_OFF): cv.string,
63  vol.Optional(CONF_PAYLOAD_ON, default=DEFAULT_PAYLOAD_ON): cv.string,
64  }
65 ).extend(MQTT_ENTITY_COMMON_SCHEMA.schema)
66 
67 DISCOVERY_SCHEMA = PLATFORM_SCHEMA_MODERN.extend({}, extra=vol.REMOVE_EXTRA)
68 
69 
71  hass: HomeAssistant,
72  config_entry: ConfigEntry,
73  async_add_entities: AddEntitiesCallback,
74 ) -> None:
75  """Set up MQTT binary sensor through YAML and through MQTT discovery."""
77  hass,
78  config_entry,
79  MqttBinarySensor,
80  binary_sensor.DOMAIN,
81  async_add_entities,
82  DISCOVERY_SCHEMA,
83  PLATFORM_SCHEMA_MODERN,
84  )
85 
86 
88  """Representation a binary sensor that is updated by MQTT."""
89 
90  _default_name = DEFAULT_NAME
91  _delay_listener: CALLBACK_TYPE | None = None
92  _entity_id_format = binary_sensor.ENTITY_ID_FORMAT
93  _expired: bool | None
94  _expire_after: int | None
95  _expiration_trigger: CALLBACK_TYPE | None = None
96 
97  async def mqtt_async_added_to_hass(self) -> None:
98  """Restore state for entities with expire_after set."""
99  if (
100  self._expire_after_expire_after
101  and (last_state := await self.async_get_last_stateasync_get_last_state()) is not None
102  and last_state.state not in [STATE_UNKNOWN, STATE_UNAVAILABLE]
103  # We might have set up a trigger already after subscribing from
104  # MqttEntity.async_added_to_hass(), then we should not restore state
105  and not self._expiration_trigger_expiration_trigger
106  ):
107  expiration_at: datetime = last_state.last_changed + timedelta(
108  seconds=self._expire_after_expire_after
109  )
110  remain_seconds = (expiration_at - dt_util.utcnow()).total_seconds()
111 
112  if remain_seconds <= 0:
113  # Skip reactivating the binary_sensor
114  _LOGGER.debug("Skip state recovery after reload for %s", self.entity_identity_id)
115  return
116  self._expired_expired = False
117  self._attr_is_on_attr_is_on = last_state.state == STATE_ON
118 
119  self._expiration_trigger_expiration_trigger = async_call_later(
120  self.hasshasshass, remain_seconds, self._value_is_expired
121  )
122  _LOGGER.debug(
123  (
124  "State recovered after reload for %s, remaining time before"
125  " expiring %s"
126  ),
127  self.entity_identity_id,
128  remain_seconds,
129  )
130 
131  async def async_will_remove_from_hass(self) -> None:
132  """Remove exprire triggers."""
133  # Clean up expire triggers
134  if self._expiration_trigger_expiration_trigger:
135  _LOGGER.debug("Clean up expire after trigger for %s", self.entity_identity_id)
136  self._expiration_trigger_expiration_trigger()
137  self._expiration_trigger_expiration_trigger = None
138  self._expired_expired = False
139  await MqttEntity.async_will_remove_from_hass(self)
140 
141  @staticmethod
142  def config_schema() -> vol.Schema:
143  """Return the config schema."""
144  return DISCOVERY_SCHEMA
145 
146  def _setup_from_config(self, config: ConfigType) -> None:
147  """(Re)Setup the entity."""
148  self._expire_after_expire_after = config.get(CONF_EXPIRE_AFTER)
149  if self._expire_after_expire_after:
150  self._expired_expired = True
151  else:
152  self._expired_expired = None
153  self._attr_force_update_attr_force_update_attr_force_update = config[CONF_FORCE_UPDATE]
154  self._attr_device_class_attr_device_class = config.get(CONF_DEVICE_CLASS)
155 
156  self._value_template_value_template = MqttValueTemplate(
157  self._config_config.get(CONF_VALUE_TEMPLATE),
158  entity=self,
159  ).async_render_with_possible_json_value
160 
161  @callback
162  def _off_delay_listener(self, now: datetime) -> None:
163  """Switch device off after a delay."""
164  self._delay_listener_delay_listener = None
165  self._attr_is_on_attr_is_on = False
166  self.async_write_ha_stateasync_write_ha_state()
167 
168  def _state_message_received(self, msg: ReceiveMessage) -> None:
169  """Handle a new received MQTT state message."""
170 
171  # auto-expire enabled?
172  if self._expire_after_expire_after:
173  # When expire_after is set, and we receive a message, assume device is
174  # not expired since it has to be to receive the message
175  self._expired_expired = False
176 
177  # Reset old trigger
178  if self._expiration_trigger_expiration_trigger:
179  self._expiration_trigger_expiration_trigger()
180 
181  # Set new trigger
182  self._expiration_trigger_expiration_trigger = async_call_later(
183  self.hasshasshass, self._expire_after_expire_after, self._value_is_expired
184  )
185 
186  payload = self._value_template_value_template(msg.payload)
187  if not payload.strip(): # No output from template, ignore
188  _LOGGER.debug(
189  (
190  "Empty template output for entity: %s with state topic: %s."
191  " Payload: '%s', with value template '%s'"
192  ),
193  self.entity_identity_id,
194  self._config_config[CONF_STATE_TOPIC],
195  msg.payload,
196  self._config_config.get(CONF_VALUE_TEMPLATE),
197  )
198  return
199 
200  if payload == self._config_config[CONF_PAYLOAD_ON]:
201  self._attr_is_on_attr_is_on = True
202  elif payload == self._config_config[CONF_PAYLOAD_OFF]:
203  self._attr_is_on_attr_is_on = False
204  elif payload == PAYLOAD_NONE:
205  self._attr_is_on_attr_is_on = None
206  else: # Payload is not for this entity
207  template_info = ""
208  if self._config_config.get(CONF_VALUE_TEMPLATE) is not None:
209  template_info = (
210  f", template output: '{payload!s}', with value template"
211  f" '{self._config.get(CONF_VALUE_TEMPLATE)!s}'"
212  )
213  _LOGGER.info(
214  (
215  "No matching payload found for entity: %s with state topic: %s."
216  " Payload: '%s'%s"
217  ),
218  self.entity_identity_id,
219  self._config_config[CONF_STATE_TOPIC],
220  msg.payload,
221  template_info,
222  )
223  return
224 
225  if self._delay_listener_delay_listener is not None:
226  self._delay_listener_delay_listener()
227  self._delay_listener_delay_listener = None
228 
229  off_delay: int | None = self._config_config.get(CONF_OFF_DELAY)
230  if self._attr_is_on_attr_is_on and off_delay is not None:
231  self._delay_listener_delay_listener = evt.async_call_later(
232  self.hasshasshass, off_delay, self._off_delay_listener
233  )
234 
235  @callback
236  def _prepare_subscribe_topics(self) -> None:
237  """(Re)Subscribe to topics."""
238  self.add_subscriptionadd_subscription(
239  CONF_STATE_TOPIC, self._state_message_received, {"_attr_is_on", "_expired"}
240  )
241 
242  async def _subscribe_topics(self) -> None:
243  """(Re)Subscribe to topics."""
244  subscription.async_subscribe_topics_internal(self.hasshasshass, self._sub_state_sub_state)
245 
246  @callback
247  def _value_is_expired(self, *_: Any) -> None:
248  """Triggered when value is expired."""
249  self._expiration_trigger_expiration_trigger = None
250  self._expired_expired = True
251 
252  self.async_write_ha_stateasync_write_ha_state()
253 
254  @property
255  def available(self) -> bool:
256  """Return true if the device is available and value has not expired."""
257  # mypy doesn't know about fget: https://github.com/python/mypy/issues/6185
258  return MqttAvailabilityMixin.available.fget(self) and ( # type: ignore[attr-defined]
259  self._expire_after_expire_after is None or not self._expired_expired
260  )
None _setup_from_config(self, ConfigType config)
Definition: entity.py:1425
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)
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
CALLBACK_TYPE async_call_later(HomeAssistant hass, float|timedelta delay, HassJob[[datetime], Coroutine[Any, Any, None]|None]|Callable[[datetime], Coroutine[Any, Any, None]|None] action)
Definition: event.py:1597