Home Assistant Unofficial Reference 2024.12.1
binary_sensor.py
Go to the documentation of this file.
1 """A sensor that monitors trends in other components."""
2 
3 from __future__ import annotations
4 
5 from collections import deque
6 from collections.abc import Mapping
7 import logging
8 import math
9 from typing import Any
10 
11 import numpy as np
12 import voluptuous as vol
13 
15  DEVICE_CLASSES_SCHEMA,
16  ENTITY_ID_FORMAT,
17  PLATFORM_SCHEMA as BINARY_SENSOR_PLATFORM_SCHEMA,
18  BinarySensorDeviceClass,
19  BinarySensorEntity,
20 )
21 from homeassistant.config_entries import ConfigEntry
22 from homeassistant.const import (
23  ATTR_ENTITY_ID,
24  ATTR_FRIENDLY_NAME,
25  CONF_ATTRIBUTE,
26  CONF_DEVICE_CLASS,
27  CONF_ENTITY_ID,
28  CONF_FRIENDLY_NAME,
29  CONF_SENSORS,
30  STATE_ON,
31  STATE_UNAVAILABLE,
32  STATE_UNKNOWN,
33 )
34 from homeassistant.core import Event, EventStateChangedData, HomeAssistant, callback
35 from homeassistant.helpers import device_registry as dr
37 from homeassistant.helpers.device import async_device_info_to_link_from_entity
38 from homeassistant.helpers.entity import generate_entity_id
39 from homeassistant.helpers.entity_platform import AddEntitiesCallback
40 from homeassistant.helpers.event import async_track_state_change_event
41 from homeassistant.helpers.reload import async_setup_reload_service
42 from homeassistant.helpers.restore_state import RestoreEntity
43 from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
44 from homeassistant.util.dt import utcnow
45 
46 from . import PLATFORMS
47 from .const import (
48  ATTR_GRADIENT,
49  ATTR_INVERT,
50  ATTR_MIN_GRADIENT,
51  ATTR_SAMPLE_COUNT,
52  ATTR_SAMPLE_DURATION,
53  CONF_INVERT,
54  CONF_MAX_SAMPLES,
55  CONF_MIN_GRADIENT,
56  CONF_MIN_SAMPLES,
57  CONF_SAMPLE_DURATION,
58  DEFAULT_MAX_SAMPLES,
59  DEFAULT_MIN_GRADIENT,
60  DEFAULT_MIN_SAMPLES,
61  DEFAULT_SAMPLE_DURATION,
62  DOMAIN,
63 )
64 
65 _LOGGER = logging.getLogger(__name__)
66 
67 
68 def _validate_min_max(data: dict[str, Any]) -> dict[str, Any]:
69  if (
70  CONF_MIN_SAMPLES in data
71  and CONF_MAX_SAMPLES in data
72  and data[CONF_MAX_SAMPLES] < data[CONF_MIN_SAMPLES]
73  ):
74  raise vol.Invalid("min_samples must be smaller than or equal to max_samples")
75  return data
76 
77 
78 SENSOR_SCHEMA = vol.All(
79  vol.Schema(
80  {
81  vol.Required(CONF_ENTITY_ID): cv.entity_id,
82  vol.Optional(CONF_ATTRIBUTE): cv.string,
83  vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA,
84  vol.Optional(CONF_FRIENDLY_NAME): cv.string,
85  vol.Optional(CONF_INVERT, default=False): cv.boolean,
86  vol.Optional(CONF_MAX_SAMPLES, default=2): cv.positive_int,
87  vol.Optional(CONF_MIN_GRADIENT, default=0.0): vol.Coerce(float),
88  vol.Optional(CONF_SAMPLE_DURATION, default=0): cv.positive_int,
89  vol.Optional(CONF_MIN_SAMPLES, default=2): cv.positive_int,
90  }
91  ),
92  _validate_min_max,
93 )
94 
95 PLATFORM_SCHEMA = BINARY_SENSOR_PLATFORM_SCHEMA.extend(
96  {vol.Required(CONF_SENSORS): cv.schema_with_slug_keys(SENSOR_SCHEMA)}
97 )
98 
99 
101  hass: HomeAssistant,
102  config: ConfigType,
103  async_add_entities: AddEntitiesCallback,
104  discovery_info: DiscoveryInfoType | None = None,
105 ) -> None:
106  """Set up the trend sensors."""
107  await async_setup_reload_service(hass, DOMAIN, PLATFORMS)
108 
109  entities = []
110  for sensor_name, sensor_config in config[CONF_SENSORS].items():
111  entities.append(
112  SensorTrend(
113  name=sensor_config.get(CONF_FRIENDLY_NAME, sensor_name),
114  entity_id=sensor_config[CONF_ENTITY_ID],
115  attribute=sensor_config.get(CONF_ATTRIBUTE),
116  invert=sensor_config[CONF_INVERT],
117  sample_duration=sensor_config[CONF_SAMPLE_DURATION],
118  min_gradient=sensor_config[CONF_MIN_GRADIENT],
119  min_samples=sensor_config[CONF_MIN_SAMPLES],
120  max_samples=sensor_config[CONF_MAX_SAMPLES],
121  device_class=sensor_config.get(CONF_DEVICE_CLASS),
122  sensor_entity_id=generate_entity_id(
123  ENTITY_ID_FORMAT, sensor_name, hass=hass
124  ),
125  )
126  )
127 
128  async_add_entities(entities)
129 
130 
132  hass: HomeAssistant,
133  entry: ConfigEntry,
134  async_add_entities: AddEntitiesCallback,
135 ) -> None:
136  """Set up trend sensor from config entry."""
137 
139  hass,
140  entry.options[CONF_ENTITY_ID],
141  )
142 
144  [
145  SensorTrend(
146  name=entry.title,
147  entity_id=entry.options[CONF_ENTITY_ID],
148  attribute=entry.options.get(CONF_ATTRIBUTE),
149  invert=entry.options[CONF_INVERT],
150  sample_duration=entry.options.get(
151  CONF_SAMPLE_DURATION, DEFAULT_SAMPLE_DURATION
152  ),
153  min_gradient=entry.options.get(CONF_MIN_GRADIENT, DEFAULT_MIN_GRADIENT),
154  min_samples=entry.options.get(CONF_MIN_SAMPLES, DEFAULT_MIN_SAMPLES),
155  max_samples=entry.options.get(CONF_MAX_SAMPLES, DEFAULT_MAX_SAMPLES),
156  unique_id=entry.entry_id,
157  device_info=device_info,
158  )
159  ]
160  )
161 
162 
164  """Representation of a trend Sensor."""
165 
166  _attr_should_poll = False
167  _gradient = 0.0
168  _state: bool | None = None
169 
170  def __init__(
171  self,
172  name: str,
173  entity_id: str,
174  attribute: str | None,
175  invert: bool,
176  sample_duration: int,
177  min_gradient: float,
178  min_samples: int,
179  max_samples: int,
180  unique_id: str | None = None,
181  device_class: BinarySensorDeviceClass | None = None,
182  sensor_entity_id: str | None = None,
183  device_info: dr.DeviceInfo | None = None,
184  ) -> None:
185  """Initialize the sensor."""
186  self._entity_id_entity_id = entity_id
187  self._attribute_attribute = attribute
188  self._invert_invert = invert
189  self._sample_duration_sample_duration = sample_duration
190  self._min_gradient_min_gradient = min_gradient
191  self._min_samples_min_samples = min_samples
192  self.samples: deque = deque(maxlen=int(max_samples))
193 
194  self._attr_name_attr_name = name
195  self._attr_device_class_attr_device_class = device_class
196  self._attr_unique_id_attr_unique_id = unique_id
197  self._attr_device_info_attr_device_info = device_info
198 
199  if sensor_entity_id:
200  self.entity_identity_identity_id = sensor_entity_id
201 
202  @property
203  def extra_state_attributes(self) -> Mapping[str, Any]:
204  """Return the state attributes of the sensor."""
205  return {
206  ATTR_ENTITY_ID: self._entity_id_entity_id,
207  ATTR_FRIENDLY_NAME: self._attr_name_attr_name,
208  ATTR_GRADIENT: self._gradient_gradient_gradient,
209  ATTR_INVERT: self._invert_invert,
210  ATTR_MIN_GRADIENT: self._min_gradient_min_gradient,
211  ATTR_SAMPLE_COUNT: len(self.samples),
212  ATTR_SAMPLE_DURATION: self._sample_duration_sample_duration,
213  }
214 
215  async def async_added_to_hass(self) -> None:
216  """Complete device setup after being added to hass."""
217 
218  @callback
219  def trend_sensor_state_listener(
220  event: Event[EventStateChangedData],
221  ) -> None:
222  """Handle state changes on the observed device."""
223  if (new_state := event.data["new_state"]) is None:
224  return
225  try:
226  if self._attribute_attribute:
227  state = new_state.attributes.get(self._attribute_attribute)
228  else:
229  state = new_state.state
230 
231  if state in (STATE_UNKNOWN, STATE_UNAVAILABLE):
232  self._attr_available_attr_available = False
233  else:
234  self._attr_available_attr_available = True
235  sample = (new_state.last_updated.timestamp(), float(state)) # type: ignore[arg-type]
236  self.samples.append(sample)
237 
238  self.async_schedule_update_ha_stateasync_schedule_update_ha_state(True)
239  except (ValueError, TypeError) as ex:
240  _LOGGER.error(ex)
241 
242  self.async_on_removeasync_on_remove(
244  self.hasshass, [self._entity_id_entity_id], trend_sensor_state_listener
245  )
246  )
247 
248  if not (state := await self.async_get_last_stateasync_get_last_state()):
249  return
250  if state.state in {STATE_UNKNOWN, STATE_UNAVAILABLE}:
251  return
252  self._attr_is_on_attr_is_on = state.state == STATE_ON
253 
254  async def async_update(self) -> None:
255  """Get the latest data and update the states."""
256  # Remove outdated samples
257  if self._sample_duration_sample_duration > 0:
258  cutoff = utcnow().timestamp() - self._sample_duration_sample_duration
259  while self.samples and self.samples[0][0] < cutoff:
260  self.samples.popleft()
261 
262  if len(self.samples) < self._min_samples_min_samples:
263  return
264 
265  # Calculate gradient of linear trend
266  await self.hasshass.async_add_executor_job(self._calculate_gradient_calculate_gradient)
267 
268  # Update state
269  self._attr_is_on_attr_is_on = (
270  abs(self._gradient_gradient_gradient) > abs(self._min_gradient_min_gradient)
271  and math.copysign(self._gradient_gradient_gradient, self._min_gradient_min_gradient) == self._gradient_gradient_gradient
272  )
273 
274  if self._invert_invert:
275  self._attr_is_on_attr_is_on = not self._attr_is_on_attr_is_on
276 
277  def _calculate_gradient(self) -> None:
278  """Compute the linear trend gradient of the current samples.
279 
280  This need run inside executor.
281  """
282  timestamps = np.array([t for t, _ in self.samples])
283  values = np.array([s for _, s in self.samples])
284  coeffs = np.polyfit(timestamps, values, 1)
285  self._gradient_gradient_gradient = coeffs[0]
None __init__(self, str name, str entity_id, str|None attribute, bool invert, int sample_duration, float min_gradient, int min_samples, int max_samples, str|None unique_id=None, BinarySensorDeviceClass|None device_class=None, str|None sensor_entity_id=None, dr.DeviceInfo|None device_info=None)
None async_schedule_update_ha_state(self, bool force_refresh=False)
Definition: entity.py:1265
None async_on_remove(self, CALLBACK_TYPE func)
Definition: entity.py:1331
None async_setup_entry(HomeAssistant hass, ConfigEntry entry, AddEntitiesCallback async_add_entities)
dict[str, Any] _validate_min_max(dict[str, Any] data)
None async_setup_platform(HomeAssistant hass, ConfigType config, AddEntitiesCallback async_add_entities, DiscoveryInfoType|None discovery_info=None)
dr.DeviceInfo|None async_device_info_to_link_from_entity(HomeAssistant hass, str entity_id_or_uuid)
Definition: device.py:28
str generate_entity_id(str entity_id_format, str|None name, list[str]|None current_ids=None, HomeAssistant|None hass=None)
Definition: entity.py:108
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
None async_setup_reload_service(HomeAssistant hass, str domain, Iterable[str] platforms)
Definition: reload.py:191