Home Assistant Unofficial Reference 2024.12.1
__init__.py
Go to the documentation of this file.
1 """Support for monitoring plants.
2 
3 DEVELOPMENT OF THE PLANT INTEGRATION IS FROZEN
4 PENDING A DESIGN EVALUATION.
5 """
6 
7 from collections import deque
8 from contextlib import suppress
9 from datetime import datetime, timedelta
10 import logging
11 
12 import voluptuous as vol
13 
14 from homeassistant.components.recorder import get_instance, history
15 from homeassistant.const import (
16  ATTR_UNIT_OF_MEASUREMENT,
17  CONF_SENSORS,
18  LIGHT_LUX,
19  PERCENTAGE,
20  STATE_OK,
21  STATE_PROBLEM,
22  STATE_UNAVAILABLE,
23  STATE_UNKNOWN,
24  UnitOfConductivity,
25  UnitOfTemperature,
26 )
27 from homeassistant.core import (
28  Event,
29  EventStateChangedData,
30  HomeAssistant,
31  State,
32  callback,
33 )
34 from homeassistant.exceptions import HomeAssistantError
36 from homeassistant.helpers.entity import Entity
37 from homeassistant.helpers.entity_component import EntityComponent
38 from homeassistant.helpers.event import async_track_state_change_event
39 from homeassistant.helpers.typing import ConfigType
40 from homeassistant.util import dt as dt_util
41 
42 from .const import (
43  ATTR_DICT_OF_UNITS_OF_MEASUREMENT,
44  ATTR_MAX_BRIGHTNESS_HISTORY,
45  ATTR_PROBLEM,
46  ATTR_SENSORS,
47  CONF_CHECK_DAYS,
48  CONF_MAX_BRIGHTNESS,
49  CONF_MAX_CONDUCTIVITY,
50  CONF_MAX_MOISTURE,
51  CONF_MAX_TEMPERATURE,
52  CONF_MIN_BATTERY_LEVEL,
53  CONF_MIN_BRIGHTNESS,
54  CONF_MIN_CONDUCTIVITY,
55  CONF_MIN_MOISTURE,
56  CONF_MIN_TEMPERATURE,
57  DEFAULT_CHECK_DAYS,
58  DEFAULT_MAX_CONDUCTIVITY,
59  DEFAULT_MAX_MOISTURE,
60  DEFAULT_MIN_BATTERY_LEVEL,
61  DEFAULT_MIN_CONDUCTIVITY,
62  DEFAULT_MIN_MOISTURE,
63  DOMAIN,
64  PROBLEM_NONE,
65  READING_BATTERY,
66  READING_BRIGHTNESS,
67  READING_CONDUCTIVITY,
68  READING_MOISTURE,
69  READING_TEMPERATURE,
70 )
71 
72 _LOGGER = logging.getLogger(__name__)
73 
74 CONF_SENSOR_BATTERY_LEVEL = READING_BATTERY
75 CONF_SENSOR_MOISTURE = READING_MOISTURE
76 CONF_SENSOR_CONDUCTIVITY = READING_CONDUCTIVITY
77 CONF_SENSOR_TEMPERATURE = READING_TEMPERATURE
78 CONF_SENSOR_BRIGHTNESS = READING_BRIGHTNESS
79 
80 
81 SCHEMA_SENSORS = vol.Schema(
82  {
83  vol.Optional(CONF_SENSOR_BATTERY_LEVEL): cv.entity_id,
84  vol.Optional(CONF_SENSOR_MOISTURE): cv.entity_id,
85  vol.Optional(CONF_SENSOR_CONDUCTIVITY): cv.entity_id,
86  vol.Optional(CONF_SENSOR_TEMPERATURE): cv.entity_id,
87  vol.Optional(CONF_SENSOR_BRIGHTNESS): cv.entity_id,
88  }
89 )
90 
91 PLANT_SCHEMA = vol.Schema(
92  {
93  vol.Required(CONF_SENSORS): vol.Schema(SCHEMA_SENSORS),
94  vol.Optional(
95  CONF_MIN_BATTERY_LEVEL, default=DEFAULT_MIN_BATTERY_LEVEL
96  ): cv.positive_int,
97  vol.Optional(CONF_MIN_TEMPERATURE): vol.Coerce(float),
98  vol.Optional(CONF_MAX_TEMPERATURE): vol.Coerce(float),
99  vol.Optional(CONF_MIN_MOISTURE, default=DEFAULT_MIN_MOISTURE): cv.positive_int,
100  vol.Optional(CONF_MAX_MOISTURE, default=DEFAULT_MAX_MOISTURE): cv.positive_int,
101  vol.Optional(
102  CONF_MIN_CONDUCTIVITY, default=DEFAULT_MIN_CONDUCTIVITY
103  ): cv.positive_int,
104  vol.Optional(
105  CONF_MAX_CONDUCTIVITY, default=DEFAULT_MAX_CONDUCTIVITY
106  ): cv.positive_int,
107  vol.Optional(CONF_MIN_BRIGHTNESS): cv.positive_int,
108  vol.Optional(CONF_MAX_BRIGHTNESS): cv.positive_int,
109  vol.Optional(CONF_CHECK_DAYS, default=DEFAULT_CHECK_DAYS): cv.positive_int,
110  }
111 )
112 
113 CONFIG_SCHEMA = vol.Schema({DOMAIN: {cv.string: PLANT_SCHEMA}}, extra=vol.ALLOW_EXTRA)
114 
115 
116 async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
117  """Set up the Plant component."""
118  component = EntityComponent[Plant](_LOGGER, DOMAIN, hass)
119 
120  entities = []
121  for plant_name, plant_config in config[DOMAIN].items():
122  _LOGGER.info("Added plant %s", plant_name)
123  entity = Plant(plant_name, plant_config)
124  entities.append(entity)
125 
126  await component.async_add_entities(entities)
127  return True
128 
129 
130 class Plant(Entity):
131  """Plant monitors the well-being of a plant.
132 
133  It also checks the measurements against
134  configurable min and max values.
135 
136  DEVELOPMENT OF THE PLANT INTEGRATION IS FROZEN
137  PENDING A DESIGN EVALUATION.
138  """
139 
140  _attr_should_poll = False
141 
142  READINGS = {
143  READING_BATTERY: {
144  ATTR_UNIT_OF_MEASUREMENT: PERCENTAGE,
145  "min": CONF_MIN_BATTERY_LEVEL,
146  },
147  READING_TEMPERATURE: {
148  ATTR_UNIT_OF_MEASUREMENT: UnitOfTemperature.CELSIUS,
149  "min": CONF_MIN_TEMPERATURE,
150  "max": CONF_MAX_TEMPERATURE,
151  },
152  READING_MOISTURE: {
153  ATTR_UNIT_OF_MEASUREMENT: PERCENTAGE,
154  "min": CONF_MIN_MOISTURE,
155  "max": CONF_MAX_MOISTURE,
156  },
157  READING_CONDUCTIVITY: {
158  ATTR_UNIT_OF_MEASUREMENT: UnitOfConductivity.MICROSIEMENS_PER_CM,
159  "min": CONF_MIN_CONDUCTIVITY,
160  "max": CONF_MAX_CONDUCTIVITY,
161  },
162  READING_BRIGHTNESS: {
163  ATTR_UNIT_OF_MEASUREMENT: LIGHT_LUX,
164  "min": CONF_MIN_BRIGHTNESS,
165  "max": CONF_MAX_BRIGHTNESS,
166  },
167  }
168 
169  def __init__(self, name, config):
170  """Initialize the Plant component."""
171  self._config_config = config
172  self._sensormap_sensormap = {}
173  self._readingmap_readingmap = {}
174  self._unit_of_measurement_unit_of_measurement = {}
175  for reading, entity_id in config["sensors"].items():
176  self._sensormap_sensormap[entity_id] = reading
177  self._readingmap_readingmap[reading] = entity_id
178  self._state_state = None
179  self._name_name = name
180  self._battery_battery = None
181  self._moisture_moisture = None
182  self._conductivity_conductivity = None
183  self._temperature_temperature = None
184  self._brightness_brightness = None
185  self._problems_problems = PROBLEM_NONE
186 
187  self._conf_check_days_conf_check_days = 3 # default check interval: 3 days
188  if CONF_CHECK_DAYS in self._config_config:
189  self._conf_check_days_conf_check_days = self._config_config[CONF_CHECK_DAYS]
190  self._brightness_history_brightness_history = DailyHistory(self._conf_check_days_conf_check_days)
191 
192  @callback
193  def _state_changed_event(self, event: Event[EventStateChangedData]) -> None:
194  """Sensor state change event."""
195  self.state_changedstate_changed(event.data["entity_id"], event.data["new_state"])
196 
197  @callback
198  def state_changed(self, entity_id: str, new_state: State | None) -> None:
199  """Update the sensor status."""
200  if new_state is None:
201  return
202  value: str | float
203  value = new_state.state
204  _LOGGER.debug("Received callback from %s with value %s", entity_id, value)
205  if value == STATE_UNKNOWN:
206  return
207 
208  reading = self._sensormap_sensormap[entity_id]
209  if reading == READING_MOISTURE:
210  if value != STATE_UNAVAILABLE:
211  value = int(float(value))
212  self._moisture_moisture = value
213  elif reading == READING_BATTERY:
214  if value != STATE_UNAVAILABLE:
215  value = int(float(value))
216  self._battery_battery = value
217  elif reading == READING_TEMPERATURE:
218  if value != STATE_UNAVAILABLE:
219  value = float(value)
220  self._temperature_temperature = value
221  elif reading == READING_CONDUCTIVITY:
222  if value != STATE_UNAVAILABLE:
223  value = int(float(value))
224  self._conductivity_conductivity = value
225  elif reading == READING_BRIGHTNESS:
226  if value != STATE_UNAVAILABLE:
227  value = int(float(value))
228  self._brightness_brightness = value
229  self._brightness_history_brightness_history.add_measurement(
230  self._brightness_brightness, new_state.last_updated
231  )
232  else:
233  raise HomeAssistantError(
234  f"Unknown reading from sensor {entity_id}: {value}"
235  )
236  if ATTR_UNIT_OF_MEASUREMENT in new_state.attributes:
237  self._unit_of_measurement_unit_of_measurement[reading] = new_state.attributes.get(
238  ATTR_UNIT_OF_MEASUREMENT
239  )
240  self._update_state_update_state()
241 
242  def _update_state(self):
243  """Update the state of the class based sensor data."""
244  result = []
245  for sensor_name in self._sensormap_sensormap.values():
246  params = self.READINGSREADINGS[sensor_name]
247  if (value := getattr(self, f"_{sensor_name}")) is not None:
248  if value == STATE_UNAVAILABLE:
249  result.append(f"{sensor_name} unavailable")
250  else:
251  if sensor_name == READING_BRIGHTNESS:
252  result.append(
253  self._check_min_check_min(
254  sensor_name, self._brightness_history_brightness_history.max, params
255  )
256  )
257  else:
258  result.append(self._check_min_check_min(sensor_name, value, params))
259  result.append(self._check_max_check_max(sensor_name, value, params))
260 
261  result = [r for r in result if r is not None]
262 
263  if result:
264  self._state_state = STATE_PROBLEM
265  self._problems_problems = ", ".join(result)
266  else:
267  self._state_state = STATE_OK
268  self._problems_problems = PROBLEM_NONE
269  _LOGGER.debug("New data processed")
270  self.async_write_ha_stateasync_write_ha_state()
271 
272  def _check_min(self, sensor_name, value, params):
273  """If configured, check the value against the defined minimum value."""
274  if "min" in params and params["min"] in self._config_config:
275  min_value = self._config_config[params["min"]]
276  if value < min_value:
277  return f"{sensor_name} low"
278  return None
279 
280  def _check_max(self, sensor_name, value, params):
281  """If configured, check the value against the defined maximum value."""
282  if "max" in params and params["max"] in self._config_config:
283  max_value = self._config_config[params["max"]]
284  if value > max_value:
285  return f"{sensor_name} high"
286  return None
287 
288  async def async_added_to_hass(self):
289  """After being added to hass, load from history."""
290  if "recorder" in self.hasshass.config.components:
291  # only use the database if it's configured
292  await get_instance(self.hasshass).async_add_executor_job(
293  self._load_history_from_db_load_history_from_db
294  )
295  self.async_write_ha_stateasync_write_ha_state()
296 
298  self.hasshass, list(self._sensormap_sensormap), self._state_changed_event_state_changed_event
299  )
300 
301  for entity_id in self._sensormap_sensormap:
302  if (state := self.hasshass.states.get(entity_id)) is not None:
303  self.state_changedstate_changed(entity_id, state)
304 
306  """Load the history of the brightness values from the database.
307 
308  This only needs to be done once during startup.
309  """
310 
311  start_date = dt_util.utcnow() - timedelta(days=self._conf_check_days_conf_check_days)
312  entity_id = self._readingmap_readingmap.get(READING_BRIGHTNESS)
313  if entity_id is None:
314  _LOGGER.debug(
315  "Not reading the history from the database as "
316  "there is no brightness sensor configured"
317  )
318  return
319  _LOGGER.debug("Initializing values for %s from the database", self._name_name)
320  lower_entity_id = entity_id.lower()
321  history_list = history.state_changes_during_period(
322  self.hasshass,
323  start_date,
324  entity_id=lower_entity_id,
325  no_attributes=True,
326  )
327  for state in history_list.get(lower_entity_id, []):
328  # filter out all None, NaN and "unknown" states
329  # only keep real values
330  with suppress(ValueError):
331  self._brightness_history_brightness_history.add_measurement(
332  int(state.state), state.last_updated
333  )
334 
335  _LOGGER.debug("Initializing from database completed")
336 
337  @property
338  def name(self):
339  """Return the name of the sensor."""
340  return self._name_name
341 
342  @property
343  def state(self):
344  """Return the state of the entity."""
345  return self._state_state
346 
347  @property
349  """Return the attributes of the entity.
350 
351  Provide the individual measurements from the
352  sensor in the attributes of the device.
353  """
354  attrib = {
355  ATTR_PROBLEM: self._problems_problems,
356  ATTR_SENSORS: self._readingmap_readingmap,
357  ATTR_DICT_OF_UNITS_OF_MEASUREMENT: self._unit_of_measurement_unit_of_measurement,
358  }
359 
360  for reading in self._sensormap_sensormap.values():
361  attrib[reading] = getattr(self, f"_{reading}")
362 
363  if self._brightness_history_brightness_history.max is not None:
364  attrib[ATTR_MAX_BRIGHTNESS_HISTORY] = self._brightness_history_brightness_history.max
365 
366  return attrib
367 
368 
370  """Stores one measurement per day for a maximum number of days.
371 
372  At the moment only the maximum value per day is kept.
373 
374  DEVELOPMENT OF THE PLANT INTEGRATION IS FROZEN
375  PENDING A DESIGN EVALUATION.
376  """
377 
378  def __init__(self, max_length):
379  """Create new DailyHistory with a maximum length of the history."""
380  self.max_lengthmax_length = max_length
381  self._days_days = None
382  self._max_dict_max_dict = {}
383  self.maxmax = None
384 
385  def add_measurement(self, value, timestamp=None):
386  """Add a new measurement for a certain day."""
387  day = (timestamp or datetime.now()).date()
388  if not isinstance(value, (int, float)):
389  return
390  if self._days_days is None:
391  self._days_days = deque()
392  self._add_day_add_day(day, value)
393  else:
394  current_day = self._days_days[-1]
395  if day == current_day:
396  self._max_dict_max_dict[day] = max(value, self._max_dict_max_dict[day])
397  elif day > current_day:
398  self._add_day_add_day(day, value)
399  else:
400  _LOGGER.warning("Received old measurement, not storing it")
401 
402  self.maxmax = max(self._max_dict_max_dict.values())
403 
404  def _add_day(self, day, value):
405  """Add a new day to the history.
406 
407  Deletes the oldest day, if the queue becomes too long.
408  """
409  if len(self._days_days) == self.max_lengthmax_length:
410  oldest = self._days_days.popleft()
411  del self._max_dict_max_dict[oldest]
412  self._days_days.append(day)
413  if not isinstance(value, (int, float)):
414  return
415  self._max_dict_max_dict[day] = value
def add_measurement(self, value, timestamp=None)
Definition: __init__.py:385
def _check_min(self, sensor_name, value, params)
Definition: __init__.py:272
None state_changed(self, str entity_id, State|None new_state)
Definition: __init__.py:198
def _check_max(self, sensor_name, value, params)
Definition: __init__.py:280
None _state_changed_event(self, Event[EventStateChangedData] event)
Definition: __init__.py:193
def __init__(self, name, config)
Definition: __init__.py:169
web.Response get(self, web.Request request, str config_key)
Definition: view.py:88
bool async_setup(HomeAssistant hass, ConfigType config)
Definition: __init__.py:116
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
Recorder get_instance(HomeAssistant hass)
Definition: recorder.py:74