Home Assistant Unofficial Reference 2024.12.1
binary_sensor.py
Go to the documentation of this file.
1 """Support for monitoring if a sensor value is below/above a threshold."""
2 
3 from __future__ import annotations
4 
5 from collections.abc import Callable, Mapping
6 import logging
7 from typing import Any, Final
8 
9 import voluptuous as vol
10 
12  DEVICE_CLASSES_SCHEMA,
13  PLATFORM_SCHEMA as BINARY_SENSOR_PLATFORM_SCHEMA,
14  BinarySensorDeviceClass,
15  BinarySensorEntity,
16 )
17 from homeassistant.config_entries import ConfigEntry
18 from homeassistant.const import (
19  ATTR_ENTITY_ID,
20  CONF_DEVICE_CLASS,
21  CONF_ENTITY_ID,
22  CONF_NAME,
23  STATE_UNAVAILABLE,
24  STATE_UNKNOWN,
25 )
26 from homeassistant.core import (
27  CALLBACK_TYPE,
28  Event,
29  EventStateChangedData,
30  HomeAssistant,
31  callback,
32 )
33 from homeassistant.helpers import config_validation as cv, entity_registry as er
34 from homeassistant.helpers.device import async_device_info_to_link_from_entity
35 from homeassistant.helpers.device_registry import DeviceInfo
36 from homeassistant.helpers.entity_platform import AddEntitiesCallback
37 from homeassistant.helpers.event import async_track_state_change_event
38 from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
39 
40 from .const import (
41  ATTR_HYSTERESIS,
42  ATTR_LOWER,
43  ATTR_POSITION,
44  ATTR_SENSOR_VALUE,
45  ATTR_TYPE,
46  ATTR_UPPER,
47  CONF_HYSTERESIS,
48  CONF_LOWER,
49  CONF_UPPER,
50  DEFAULT_HYSTERESIS,
51  POSITION_ABOVE,
52  POSITION_BELOW,
53  POSITION_IN_RANGE,
54  POSITION_UNKNOWN,
55  TYPE_LOWER,
56  TYPE_RANGE,
57  TYPE_UPPER,
58 )
59 
60 _LOGGER = logging.getLogger(__name__)
61 
62 DEFAULT_NAME: Final = "Threshold"
63 
64 
65 def no_missing_threshold(value: dict) -> dict:
66  """Validate data point list is greater than polynomial degrees."""
67  if value.get(CONF_LOWER) is None and value.get(CONF_UPPER) is None:
68  raise vol.Invalid("Lower or Upper thresholds are not provided")
69 
70  return value
71 
72 
73 PLATFORM_SCHEMA = vol.All(
74  BINARY_SENSOR_PLATFORM_SCHEMA.extend(
75  {
76  vol.Required(CONF_ENTITY_ID): cv.entity_id,
77  vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA,
78  vol.Optional(CONF_HYSTERESIS, default=DEFAULT_HYSTERESIS): vol.Coerce(
79  float
80  ),
81  vol.Optional(CONF_LOWER): vol.Coerce(float),
82  vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
83  vol.Optional(CONF_UPPER): vol.Coerce(float),
84  }
85  ),
86  no_missing_threshold,
87 )
88 
89 
91  hass: HomeAssistant,
92  config_entry: ConfigEntry,
93  async_add_entities: AddEntitiesCallback,
94 ) -> None:
95  """Initialize threshold config entry."""
96  registry = er.async_get(hass)
97  device_class = None
98  entity_id = er.async_validate_entity_id(
99  registry, config_entry.options[CONF_ENTITY_ID]
100  )
101 
103  hass,
104  entity_id,
105  )
106 
107  hysteresis = config_entry.options[CONF_HYSTERESIS]
108  lower = config_entry.options[CONF_LOWER]
109  name = config_entry.title
110  unique_id = config_entry.entry_id
111  upper = config_entry.options[CONF_UPPER]
112 
114  [
116  entity_id,
117  name,
118  lower,
119  upper,
120  hysteresis,
121  device_class,
122  unique_id,
123  device_info=device_info,
124  )
125  ]
126  )
127 
128 
130  hass: HomeAssistant,
131  config: ConfigType,
132  async_add_entities: AddEntitiesCallback,
133  discovery_info: DiscoveryInfoType | None = None,
134 ) -> None:
135  """Set up the Threshold sensor."""
136  entity_id: str = config[CONF_ENTITY_ID]
137  name: str = config[CONF_NAME]
138  lower: float | None = config.get(CONF_LOWER)
139  upper: float | None = config.get(CONF_UPPER)
140  hysteresis: float = config[CONF_HYSTERESIS]
141  device_class: BinarySensorDeviceClass | None = config.get(CONF_DEVICE_CLASS)
142 
144  [
146  entity_id, name, lower, upper, hysteresis, device_class, None
147  )
148  ],
149  )
150 
151 
152 def _threshold_type(lower: float | None, upper: float | None) -> str:
153  """Return the type of threshold this sensor represents."""
154  if lower is not None and upper is not None:
155  return TYPE_RANGE
156  if lower is not None:
157  return TYPE_LOWER
158  return TYPE_UPPER
159 
160 
162  """Representation of a Threshold sensor."""
163 
164  _attr_should_poll = False
165  _unrecorded_attributes = frozenset(
166  {ATTR_ENTITY_ID, ATTR_HYSTERESIS, ATTR_LOWER, ATTR_TYPE, ATTR_UPPER}
167  )
168 
169  def __init__(
170  self,
171  entity_id: str,
172  name: str,
173  lower: float | None,
174  upper: float | None,
175  hysteresis: float,
176  device_class: BinarySensorDeviceClass | None,
177  unique_id: str | None,
178  device_info: DeviceInfo | None = None,
179  ) -> None:
180  """Initialize the Threshold sensor."""
181  self._preview_callback_preview_callback: Callable[[str, Mapping[str, Any]], None] | None = None
182  self._attr_unique_id_attr_unique_id = unique_id
183  self._attr_device_info_attr_device_info = device_info
184  self._entity_id_entity_id = entity_id
185  self._attr_name_attr_name = name
186  if lower is not None:
187  self._threshold_lower_threshold_lower = lower
188  if upper is not None:
189  self._threshold_upper_threshold_upper = upper
190  self.threshold_typethreshold_type = _threshold_type(lower, upper)
191  self._hysteresis: float = hysteresis
192  self._attr_device_class_attr_device_class = device_class
193  self._state_position_state_position = POSITION_UNKNOWN
194  self.sensor_valuesensor_value: float | None = None
195 
196  async def async_added_to_hass(self) -> None:
197  """Run when entity about to be added to hass."""
198  self._async_setup_sensor_async_setup_sensor()
199 
200  @callback
201  def _async_setup_sensor(self) -> None:
202  """Set up the sensor and start tracking state changes."""
203 
204  def _update_sensor_state() -> None:
205  """Handle sensor state changes."""
206  if (new_state := self.hasshass.states.get(self._entity_id_entity_id)) is None:
207  return
208 
209  try:
210  self.sensor_valuesensor_value = (
211  None
212  if new_state.state in [STATE_UNKNOWN, STATE_UNAVAILABLE]
213  else float(new_state.state)
214  )
215  except (ValueError, TypeError):
216  self.sensor_valuesensor_value = None
217  _LOGGER.warning("State is not numerical")
218 
219  self._update_state_update_state()
220 
221  if self._preview_callback_preview_callback:
222  calculated_state = self._async_calculate_state_async_calculate_state()
223  self._preview_callback_preview_callback(
224  calculated_state.state, calculated_state.attributes
225  )
226 
227  @callback
228  def async_threshold_sensor_state_listener(
229  event: Event[EventStateChangedData],
230  ) -> None:
231  """Handle sensor state changes."""
232  _update_sensor_state()
233 
234  # only write state to the state machine if we are not in preview mode
235  if not self._preview_callback_preview_callback:
236  self.async_write_ha_stateasync_write_ha_state()
237 
238  self.async_on_removeasync_on_remove(
240  self.hasshass, [self._entity_id_entity_id], async_threshold_sensor_state_listener
241  )
242  )
243  _update_sensor_state()
244 
245  @property
246  def extra_state_attributes(self) -> dict[str, Any]:
247  """Return the state attributes of the sensor."""
248  return {
249  ATTR_ENTITY_ID: self._entity_id_entity_id,
250  ATTR_HYSTERESIS: self._hysteresis,
251  ATTR_LOWER: getattr(self, "_threshold_lower", None),
252  ATTR_POSITION: self._state_position_state_position,
253  ATTR_SENSOR_VALUE: self.sensor_valuesensor_value,
254  ATTR_TYPE: self.threshold_typethreshold_type,
255  ATTR_UPPER: getattr(self, "_threshold_upper", None),
256  }
257 
258  @callback
259  def _update_state(self) -> None:
260  """Update the state."""
261 
262  def below(sensor_value: float, threshold: float) -> bool:
263  """Determine if the sensor value is below a threshold."""
264  return sensor_value < (threshold - self._hysteresis)
265 
266  def above(sensor_value: float, threshold: float) -> bool:
267  """Determine if the sensor value is above a threshold."""
268  return sensor_value > (threshold + self._hysteresis)
269 
270  if self.sensor_valuesensor_value is None:
271  self._state_position_state_position = POSITION_UNKNOWN
272  self._attr_is_on_attr_is_on = None
273  return
274 
275  if self.threshold_typethreshold_type == TYPE_LOWER:
276  if self._attr_is_on_attr_is_on is None:
277  self._attr_is_on_attr_is_on = False
278  self._state_position_state_position = POSITION_ABOVE
279 
280  if below(self.sensor_valuesensor_value, self._threshold_lower_threshold_lower):
281  self._state_position_state_position = POSITION_BELOW
282  self._attr_is_on_attr_is_on = True
283  elif above(self.sensor_valuesensor_value, self._threshold_lower_threshold_lower):
284  self._state_position_state_position = POSITION_ABOVE
285  self._attr_is_on_attr_is_on = False
286  return
287 
288  if self.threshold_typethreshold_type == TYPE_UPPER:
289  assert self._threshold_upper_threshold_upper is not None
290 
291  if self._attr_is_on_attr_is_on is None:
292  self._attr_is_on_attr_is_on = False
293  self._state_position_state_position = POSITION_BELOW
294 
295  if above(self.sensor_valuesensor_value, self._threshold_upper_threshold_upper):
296  self._state_position_state_position = POSITION_ABOVE
297  self._attr_is_on_attr_is_on = True
298  elif below(self.sensor_valuesensor_value, self._threshold_upper_threshold_upper):
299  self._state_position_state_position = POSITION_BELOW
300  self._attr_is_on_attr_is_on = False
301  return
302 
303  if self.threshold_typethreshold_type == TYPE_RANGE:
304  if self._attr_is_on_attr_is_on is None:
305  self._attr_is_on_attr_is_on = True
306  self._state_position_state_position = POSITION_IN_RANGE
307 
308  if below(self.sensor_valuesensor_value, self._threshold_lower_threshold_lower):
309  self._state_position_state_position = POSITION_BELOW
310  self._attr_is_on_attr_is_on = False
311  if above(self.sensor_valuesensor_value, self._threshold_upper_threshold_upper):
312  self._state_position_state_position = POSITION_ABOVE
313  self._attr_is_on_attr_is_on = False
314  elif above(self.sensor_valuesensor_value, self._threshold_lower_threshold_lower) and below(
315  self.sensor_valuesensor_value, self._threshold_upper_threshold_upper
316  ):
317  self._state_position_state_position = POSITION_IN_RANGE
318  self._attr_is_on_attr_is_on = True
319  return
320 
321  @callback
323  self,
324  preview_callback: Callable[[str, Mapping[str, Any]], None],
325  ) -> CALLBACK_TYPE:
326  """Render a preview."""
327  # abort early if there is no entity_id
328  # as without we can't track changes
329  # or if neither lower nor upper thresholds are set
330  if not self._entity_id_entity_id or (
331  not hasattr(self, "_threshold_lower")
332  and not hasattr(self, "_threshold_upper")
333  ):
334  self._attr_available_attr_available = False
335  calculated_state = self._async_calculate_state_async_calculate_state()
336  preview_callback(calculated_state.state, calculated_state.attributes)
337  return self._call_on_remove_callbacks_call_on_remove_callbacks
338 
339  self._preview_callback_preview_callback = preview_callback
340 
341  self._async_setup_sensor_async_setup_sensor()
342  return self._call_on_remove_callbacks_call_on_remove_callbacks
None __init__(self, str entity_id, str name, float|None lower, float|None upper, float hysteresis, BinarySensorDeviceClass|None device_class, str|None unique_id, DeviceInfo|None device_info=None)
CALLBACK_TYPE async_start_preview(self, Callable[[str, Mapping[str, Any]], None] preview_callback)
CalculatedState _async_calculate_state(self)
Definition: entity.py:1059
None async_on_remove(self, CALLBACK_TYPE func)
Definition: entity.py:1331
None async_setup_entry(HomeAssistant hass, ConfigEntry config_entry, AddEntitiesCallback async_add_entities)
None async_setup_platform(HomeAssistant hass, ConfigType config, AddEntitiesCallback async_add_entities, DiscoveryInfoType|None discovery_info=None)
str _threshold_type(float|None lower, float|None upper)
dr.DeviceInfo|None async_device_info_to_link_from_entity(HomeAssistant hass, str entity_id_or_uuid)
Definition: device.py:28
CALLBACK_TYPE async_track_state_change_event(HomeAssistant hass, str|Iterable[str] entity_ids, Callable[[Event[EventStateChangedData]], Any] action, HassJobType|None job_type=None)
Definition: event.py:314