2019-04-03 15:40:03 +00:00
|
|
|
"""Use Bayesian Inference to trigger a binary sensor."""
|
2022-01-03 12:10:41 +00:00
|
|
|
from __future__ import annotations
|
|
|
|
|
2017-08-29 21:53:41 +00:00
|
|
|
from collections import OrderedDict
|
2022-10-07 20:23:25 +00:00
|
|
|
from collections.abc import Callable
|
2020-08-23 07:59:26 +00:00
|
|
|
import logging
|
2022-09-29 17:24:06 +00:00
|
|
|
from typing import Any
|
2022-10-07 20:23:25 +00:00
|
|
|
from uuid import UUID
|
2017-08-29 21:53:41 +00:00
|
|
|
|
|
|
|
import voluptuous as vol
|
|
|
|
|
2022-10-07 20:23:25 +00:00
|
|
|
from homeassistant.components.binary_sensor import (
|
|
|
|
PLATFORM_SCHEMA,
|
|
|
|
BinarySensorDeviceClass,
|
|
|
|
BinarySensorEntity,
|
|
|
|
)
|
2017-08-29 21:53:41 +00:00
|
|
|
from homeassistant.const import (
|
2019-07-31 19:25:30 +00:00
|
|
|
CONF_ABOVE,
|
|
|
|
CONF_BELOW,
|
|
|
|
CONF_DEVICE_CLASS,
|
|
|
|
CONF_ENTITY_ID,
|
|
|
|
CONF_NAME,
|
|
|
|
CONF_PLATFORM,
|
|
|
|
CONF_STATE,
|
|
|
|
CONF_VALUE_TEMPLATE,
|
2022-09-26 02:02:35 +00:00
|
|
|
STATE_UNAVAILABLE,
|
2019-07-31 19:25:30 +00:00
|
|
|
STATE_UNKNOWN,
|
|
|
|
)
|
2022-10-07 20:23:25 +00:00
|
|
|
from homeassistant.core import Event, HomeAssistant, callback
|
2021-02-08 09:47:57 +00:00
|
|
|
from homeassistant.exceptions import ConditionError, TemplateError
|
2017-08-29 21:53:41 +00:00
|
|
|
from homeassistant.helpers import condition
|
2019-02-14 00:39:53 +00:00
|
|
|
import homeassistant.helpers.config_validation as cv
|
2022-01-03 12:10:41 +00:00
|
|
|
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
2020-08-23 07:59:26 +00:00
|
|
|
from homeassistant.helpers.event import (
|
2020-09-01 00:07:40 +00:00
|
|
|
TrackTemplate,
|
2022-10-07 20:23:25 +00:00
|
|
|
TrackTemplateResult,
|
2022-10-09 18:30:38 +00:00
|
|
|
TrackTemplateResultInfo,
|
2020-08-23 07:59:26 +00:00
|
|
|
async_track_state_change_event,
|
|
|
|
async_track_template_result,
|
|
|
|
)
|
2020-09-08 07:41:17 +00:00
|
|
|
from homeassistant.helpers.reload import async_setup_reload_service
|
2022-10-07 20:23:25 +00:00
|
|
|
from homeassistant.helpers.template import Template, result_as_boolean
|
2022-01-03 12:10:41 +00:00
|
|
|
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
|
2017-08-29 21:53:41 +00:00
|
|
|
|
2020-09-08 07:41:17 +00:00
|
|
|
from . import DOMAIN, PLATFORMS
|
2022-10-04 15:16:39 +00:00
|
|
|
from .const import (
|
|
|
|
ATTR_OBSERVATIONS,
|
|
|
|
ATTR_OCCURRED_OBSERVATION_ENTITIES,
|
|
|
|
ATTR_PROBABILITY,
|
|
|
|
ATTR_PROBABILITY_THRESHOLD,
|
|
|
|
CONF_OBSERVATIONS,
|
|
|
|
CONF_P_GIVEN_F,
|
|
|
|
CONF_P_GIVEN_T,
|
|
|
|
CONF_PRIOR,
|
|
|
|
CONF_PROBABILITY_THRESHOLD,
|
|
|
|
CONF_TEMPLATE,
|
|
|
|
CONF_TO_STATE,
|
|
|
|
DEFAULT_NAME,
|
|
|
|
DEFAULT_PROBABILITY_THRESHOLD,
|
|
|
|
)
|
|
|
|
from .helpers import Observation
|
2022-09-29 17:24:06 +00:00
|
|
|
from .repairs import raise_mirrored_entries, raise_no_prob_given_false
|
2020-09-08 07:41:17 +00:00
|
|
|
|
2020-08-23 07:59:26 +00:00
|
|
|
_LOGGER = logging.getLogger(__name__)
|
|
|
|
|
|
|
|
|
2019-07-31 19:25:30 +00:00
|
|
|
NUMERIC_STATE_SCHEMA = vol.Schema(
|
|
|
|
{
|
|
|
|
CONF_PLATFORM: "numeric_state",
|
|
|
|
vol.Required(CONF_ENTITY_ID): cv.entity_id,
|
|
|
|
vol.Optional(CONF_ABOVE): vol.Coerce(float),
|
|
|
|
vol.Optional(CONF_BELOW): vol.Coerce(float),
|
|
|
|
vol.Required(CONF_P_GIVEN_T): vol.Coerce(float),
|
2022-09-29 17:24:06 +00:00
|
|
|
vol.Optional(CONF_P_GIVEN_F): vol.Coerce(float),
|
2019-07-31 19:25:30 +00:00
|
|
|
},
|
|
|
|
required=True,
|
|
|
|
)
|
|
|
|
|
|
|
|
STATE_SCHEMA = vol.Schema(
|
|
|
|
{
|
|
|
|
CONF_PLATFORM: CONF_STATE,
|
|
|
|
vol.Required(CONF_ENTITY_ID): cv.entity_id,
|
|
|
|
vol.Required(CONF_TO_STATE): cv.string,
|
|
|
|
vol.Required(CONF_P_GIVEN_T): vol.Coerce(float),
|
2022-09-29 17:24:06 +00:00
|
|
|
vol.Optional(CONF_P_GIVEN_F): vol.Coerce(float),
|
2019-07-31 19:25:30 +00:00
|
|
|
},
|
|
|
|
required=True,
|
|
|
|
)
|
|
|
|
|
|
|
|
TEMPLATE_SCHEMA = vol.Schema(
|
|
|
|
{
|
|
|
|
CONF_PLATFORM: CONF_TEMPLATE,
|
|
|
|
vol.Required(CONF_VALUE_TEMPLATE): cv.template,
|
|
|
|
vol.Required(CONF_P_GIVEN_T): vol.Coerce(float),
|
2022-09-29 17:24:06 +00:00
|
|
|
vol.Optional(CONF_P_GIVEN_F): vol.Coerce(float),
|
2019-07-31 19:25:30 +00:00
|
|
|
},
|
|
|
|
required=True,
|
|
|
|
)
|
|
|
|
|
|
|
|
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(
|
|
|
|
{
|
|
|
|
vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
|
|
|
|
vol.Optional(CONF_DEVICE_CLASS): cv.string,
|
|
|
|
vol.Required(CONF_OBSERVATIONS): vol.Schema(
|
|
|
|
vol.All(
|
|
|
|
cv.ensure_list,
|
|
|
|
[vol.Any(NUMERIC_STATE_SCHEMA, STATE_SCHEMA, TEMPLATE_SCHEMA)],
|
|
|
|
)
|
|
|
|
),
|
|
|
|
vol.Required(CONF_PRIOR): vol.Coerce(float),
|
|
|
|
vol.Optional(
|
|
|
|
CONF_PROBABILITY_THRESHOLD, default=DEFAULT_PROBABILITY_THRESHOLD
|
|
|
|
): vol.Coerce(float),
|
|
|
|
}
|
|
|
|
)
|
2017-08-29 21:53:41 +00:00
|
|
|
|
|
|
|
|
2022-10-07 20:23:25 +00:00
|
|
|
def update_probability(
|
|
|
|
prior: float, prob_given_true: float, prob_given_false: float
|
|
|
|
) -> float:
|
2017-08-29 21:53:41 +00:00
|
|
|
"""Update probability using Bayes' rule."""
|
2020-03-31 16:41:29 +00:00
|
|
|
numerator = prob_given_true * prior
|
|
|
|
denominator = numerator + prob_given_false * (1 - prior)
|
2020-05-08 22:10:17 +00:00
|
|
|
return numerator / denominator
|
2017-08-29 21:53:41 +00:00
|
|
|
|
|
|
|
|
2022-01-03 12:10:41 +00:00
|
|
|
async def async_setup_platform(
|
|
|
|
hass: HomeAssistant,
|
|
|
|
config: ConfigType,
|
|
|
|
async_add_entities: AddEntitiesCallback,
|
|
|
|
discovery_info: DiscoveryInfoType | None = None,
|
|
|
|
) -> None:
|
2017-10-24 07:12:01 +00:00
|
|
|
"""Set up the Bayesian Binary sensor."""
|
2020-09-08 07:41:17 +00:00
|
|
|
await async_setup_reload_service(hass, DOMAIN, PLATFORMS)
|
|
|
|
|
2022-10-07 20:23:25 +00:00
|
|
|
name: str = config[CONF_NAME]
|
|
|
|
observations: list[ConfigType] = config[CONF_OBSERVATIONS]
|
|
|
|
prior: float = config[CONF_PRIOR]
|
|
|
|
probability_threshold: float = config[CONF_PROBABILITY_THRESHOLD]
|
|
|
|
device_class: BinarySensorDeviceClass | None = config.get(CONF_DEVICE_CLASS)
|
2017-08-29 21:53:41 +00:00
|
|
|
|
2022-09-29 17:24:06 +00:00
|
|
|
# Should deprecate in some future version (2022.10 at time of writing) & make prob_given_false required in schemas.
|
|
|
|
broken_observations: list[dict[str, Any]] = []
|
|
|
|
for observation in observations:
|
|
|
|
if CONF_P_GIVEN_F not in observation:
|
|
|
|
text: str = f"{name}/{observation.get(CONF_ENTITY_ID,'')}{observation.get(CONF_VALUE_TEMPLATE,'')}"
|
2022-10-07 20:23:25 +00:00
|
|
|
raise_no_prob_given_false(hass, text)
|
2022-09-29 17:24:06 +00:00
|
|
|
_LOGGER.error("Missing prob_given_false YAML entry for %s", text)
|
|
|
|
broken_observations.append(observation)
|
|
|
|
observations = [x for x in observations if x not in broken_observations]
|
|
|
|
|
2019-07-31 19:25:30 +00:00
|
|
|
async_add_entities(
|
|
|
|
[
|
|
|
|
BayesianBinarySensor(
|
|
|
|
name, prior, observations, probability_threshold, device_class
|
|
|
|
)
|
2020-08-23 07:59:26 +00:00
|
|
|
]
|
2019-07-31 19:25:30 +00:00
|
|
|
)
|
2017-08-29 21:53:41 +00:00
|
|
|
|
|
|
|
|
2020-04-23 19:57:07 +00:00
|
|
|
class BayesianBinarySensor(BinarySensorEntity):
|
2017-08-29 21:53:41 +00:00
|
|
|
"""Representation of a Bayesian sensor."""
|
|
|
|
|
2021-07-11 20:37:41 +00:00
|
|
|
_attr_should_poll = False
|
|
|
|
|
2022-10-07 20:23:25 +00:00
|
|
|
def __init__(
|
|
|
|
self,
|
|
|
|
name: str,
|
|
|
|
prior: float,
|
|
|
|
observations: list[ConfigType],
|
|
|
|
probability_threshold: float,
|
|
|
|
device_class: BinarySensorDeviceClass | None,
|
|
|
|
) -> None:
|
2017-08-29 21:53:41 +00:00
|
|
|
"""Initialize the Bayesian sensor."""
|
2021-07-11 20:37:41 +00:00
|
|
|
self._attr_name = name
|
2022-10-04 15:16:39 +00:00
|
|
|
self._observations = [
|
|
|
|
Observation(
|
|
|
|
entity_id=observation.get(CONF_ENTITY_ID),
|
|
|
|
platform=observation[CONF_PLATFORM],
|
|
|
|
prob_given_false=observation[CONF_P_GIVEN_F],
|
|
|
|
prob_given_true=observation[CONF_P_GIVEN_T],
|
|
|
|
observed=None,
|
|
|
|
to_state=observation.get(CONF_TO_STATE),
|
|
|
|
above=observation.get(CONF_ABOVE),
|
|
|
|
below=observation.get(CONF_BELOW),
|
|
|
|
value_template=observation.get(CONF_VALUE_TEMPLATE),
|
|
|
|
)
|
|
|
|
for observation in observations
|
|
|
|
]
|
2017-08-29 21:53:41 +00:00
|
|
|
self._probability_threshold = probability_threshold
|
2021-07-11 20:37:41 +00:00
|
|
|
self._attr_device_class = device_class
|
|
|
|
self._attr_is_on = False
|
2022-10-09 18:30:38 +00:00
|
|
|
self._callbacks: list[TrackTemplateResultInfo] = []
|
2020-08-23 07:59:26 +00:00
|
|
|
|
2017-08-29 21:53:41 +00:00
|
|
|
self.prior = prior
|
|
|
|
self.probability = prior
|
|
|
|
|
2022-10-07 20:23:25 +00:00
|
|
|
self.current_observations: OrderedDict[UUID, Observation] = OrderedDict({})
|
2020-02-25 05:27:59 +00:00
|
|
|
|
2020-03-31 16:41:29 +00:00
|
|
|
self.observations_by_entity = self._build_observations_by_entity()
|
2020-08-23 07:59:26 +00:00
|
|
|
self.observations_by_template = self._build_observations_by_template()
|
2017-08-29 21:53:41 +00:00
|
|
|
|
2022-10-07 20:23:25 +00:00
|
|
|
self.observation_handlers: dict[str, Callable[[Observation], bool | None]] = {
|
2019-07-31 19:25:30 +00:00
|
|
|
"numeric_state": self._process_numeric_state,
|
|
|
|
"state": self._process_state,
|
2022-09-26 02:02:35 +00:00
|
|
|
"multi_state": self._process_multi_state,
|
2017-08-29 21:53:41 +00:00
|
|
|
}
|
|
|
|
|
2022-08-19 07:54:13 +00:00
|
|
|
async def async_added_to_hass(self) -> None:
|
2020-03-31 16:41:29 +00:00
|
|
|
"""
|
|
|
|
Call when entity about to be added.
|
2019-07-31 19:25:30 +00:00
|
|
|
|
2020-03-31 16:41:29 +00:00
|
|
|
All relevant update logic for instance attributes occurs within this closure.
|
|
|
|
Other methods in this class are designed to avoid directly modifying instance
|
|
|
|
attributes, by instead focusing on returning relevant data back to this method.
|
2017-08-29 21:53:41 +00:00
|
|
|
|
2020-03-31 16:41:29 +00:00
|
|
|
The goal of this method is to ensure that `self.current_observations` and `self.probability`
|
|
|
|
are set on a best-effort basis when this entity is register with hass.
|
2017-09-12 16:52:09 +00:00
|
|
|
|
2020-03-31 16:41:29 +00:00
|
|
|
In addition, this method must register the state listener defined within, which
|
|
|
|
will be called any time a relevant entity changes its state.
|
|
|
|
"""
|
2017-08-29 21:53:41 +00:00
|
|
|
|
2020-03-31 16:41:29 +00:00
|
|
|
@callback
|
2022-10-07 20:23:25 +00:00
|
|
|
def async_threshold_sensor_state_listener(event: Event) -> None:
|
2020-03-31 16:41:29 +00:00
|
|
|
"""
|
|
|
|
Handle sensor state changes.
|
2017-08-29 21:53:41 +00:00
|
|
|
|
2020-03-31 16:41:29 +00:00
|
|
|
When a state changes, we must update our list of current observations,
|
|
|
|
then calculate the new probability.
|
|
|
|
"""
|
|
|
|
|
2022-10-07 20:23:25 +00:00
|
|
|
entity: str = event.data[CONF_ENTITY_ID]
|
2020-07-14 19:40:01 +00:00
|
|
|
|
2020-03-31 16:41:29 +00:00
|
|
|
self.current_observations.update(self._record_entity_observations(entity))
|
2020-09-17 14:47:23 +00:00
|
|
|
self.async_set_context(event.context)
|
2020-08-23 07:59:26 +00:00
|
|
|
self._recalculate_and_write_state()
|
2017-08-29 21:53:41 +00:00
|
|
|
|
2020-08-23 07:59:26 +00:00
|
|
|
self.async_on_remove(
|
|
|
|
async_track_state_change_event(
|
|
|
|
self.hass,
|
|
|
|
list(self.observations_by_entity),
|
|
|
|
async_threshold_sensor_state_listener,
|
|
|
|
)
|
|
|
|
)
|
|
|
|
|
|
|
|
@callback
|
2022-10-07 20:23:25 +00:00
|
|
|
def _async_template_result_changed(
|
|
|
|
event: Event | None, updates: list[TrackTemplateResult]
|
|
|
|
) -> None:
|
2020-09-01 00:07:40 +00:00
|
|
|
track_template_result = updates.pop()
|
|
|
|
template = track_template_result.template
|
|
|
|
result = track_template_result.result
|
2022-10-07 20:23:25 +00:00
|
|
|
entity: str | None = (
|
|
|
|
None if event is None else event.data.get(CONF_ENTITY_ID)
|
|
|
|
)
|
2020-08-23 07:59:26 +00:00
|
|
|
if isinstance(result, TemplateError):
|
|
|
|
_LOGGER.error(
|
|
|
|
"TemplateError('%s') "
|
|
|
|
"while processing template '%s' "
|
|
|
|
"in entity '%s'",
|
|
|
|
result,
|
|
|
|
template,
|
|
|
|
self.entity_id,
|
|
|
|
)
|
|
|
|
|
2022-10-04 15:16:39 +00:00
|
|
|
observed = None
|
2020-08-23 07:59:26 +00:00
|
|
|
else:
|
2022-10-04 15:16:39 +00:00
|
|
|
observed = result_as_boolean(result)
|
2020-08-23 07:59:26 +00:00
|
|
|
|
2022-10-04 15:16:39 +00:00
|
|
|
for observation in self.observations_by_template[template]:
|
|
|
|
observation.observed = observed
|
|
|
|
|
|
|
|
# in some cases a template may update because of the absence of an entity
|
|
|
|
if entity is not None:
|
2022-10-07 20:23:25 +00:00
|
|
|
observation.entity_id = entity
|
2022-10-04 15:16:39 +00:00
|
|
|
|
|
|
|
self.current_observations[observation.id] = observation
|
2020-08-23 07:59:26 +00:00
|
|
|
|
2020-09-17 14:47:23 +00:00
|
|
|
if event:
|
|
|
|
self.async_set_context(event.context)
|
2020-08-23 07:59:26 +00:00
|
|
|
self._recalculate_and_write_state()
|
|
|
|
|
|
|
|
for template in self.observations_by_template:
|
|
|
|
info = async_track_template_result(
|
2020-09-01 00:07:40 +00:00
|
|
|
self.hass,
|
|
|
|
[TrackTemplate(template, None)],
|
|
|
|
_async_template_result_changed,
|
2020-08-23 07:59:26 +00:00
|
|
|
)
|
|
|
|
|
|
|
|
self._callbacks.append(info)
|
|
|
|
self.async_on_remove(info.async_remove)
|
|
|
|
info.async_refresh()
|
2017-08-29 21:53:41 +00:00
|
|
|
|
2020-03-31 16:41:29 +00:00
|
|
|
self.current_observations.update(self._initialize_current_observations())
|
|
|
|
self.probability = self._calculate_new_probability()
|
2022-10-07 20:23:25 +00:00
|
|
|
self._attr_is_on = self.probability >= self._probability_threshold
|
2020-08-23 07:59:26 +00:00
|
|
|
|
2022-09-29 01:39:15 +00:00
|
|
|
# detect mirrored entries
|
|
|
|
for entity, observations in self.observations_by_entity.items():
|
|
|
|
raise_mirrored_entries(
|
|
|
|
self.hass, observations, text=f"{self._attr_name}/{entity}"
|
|
|
|
)
|
|
|
|
|
2022-10-07 20:23:25 +00:00
|
|
|
all_template_observations: list[Observation] = []
|
|
|
|
for observations in self.observations_by_template.values():
|
|
|
|
all_template_observations.append(observations[0])
|
2022-09-29 01:39:15 +00:00
|
|
|
if len(all_template_observations) == 2:
|
|
|
|
raise_mirrored_entries(
|
|
|
|
self.hass,
|
|
|
|
all_template_observations,
|
2022-10-04 15:16:39 +00:00
|
|
|
text=f"{self._attr_name}/{all_template_observations[0].value_template}",
|
2022-09-29 01:39:15 +00:00
|
|
|
)
|
|
|
|
|
2020-08-23 07:59:26 +00:00
|
|
|
@callback
|
2022-10-07 20:23:25 +00:00
|
|
|
def _recalculate_and_write_state(self) -> None:
|
2020-08-23 07:59:26 +00:00
|
|
|
self.probability = self._calculate_new_probability()
|
2021-07-11 20:37:41 +00:00
|
|
|
self._attr_is_on = bool(self.probability >= self._probability_threshold)
|
2020-08-23 07:59:26 +00:00
|
|
|
self.async_write_ha_state()
|
2017-08-29 21:53:41 +00:00
|
|
|
|
2022-10-07 20:23:25 +00:00
|
|
|
def _initialize_current_observations(self) -> OrderedDict[UUID, Observation]:
|
|
|
|
local_observations: OrderedDict[UUID, Observation] = OrderedDict({})
|
2020-03-31 16:41:29 +00:00
|
|
|
for entity in self.observations_by_entity:
|
|
|
|
local_observations.update(self._record_entity_observations(entity))
|
|
|
|
return local_observations
|
|
|
|
|
2022-10-07 20:23:25 +00:00
|
|
|
def _record_entity_observations(
|
|
|
|
self, entity: str
|
|
|
|
) -> OrderedDict[UUID, Observation]:
|
|
|
|
local_observations: OrderedDict[UUID, Observation] = OrderedDict({})
|
2020-03-31 16:41:29 +00:00
|
|
|
|
2022-10-04 15:16:39 +00:00
|
|
|
for observation in self.observations_by_entity[entity]:
|
|
|
|
platform = observation.platform
|
2017-08-29 21:53:41 +00:00
|
|
|
|
2022-10-07 20:23:25 +00:00
|
|
|
observation.observed = self.observation_handlers[platform](observation)
|
2017-08-29 21:53:41 +00:00
|
|
|
|
2022-10-04 15:16:39 +00:00
|
|
|
local_observations[observation.id] = observation
|
2020-03-31 16:41:29 +00:00
|
|
|
|
|
|
|
return local_observations
|
|
|
|
|
2022-10-07 20:23:25 +00:00
|
|
|
def _calculate_new_probability(self) -> float:
|
2020-03-31 16:41:29 +00:00
|
|
|
prior = self.prior
|
|
|
|
|
2022-10-04 15:16:39 +00:00
|
|
|
for observation in self.current_observations.values():
|
2022-10-07 20:23:25 +00:00
|
|
|
if observation.observed is True:
|
|
|
|
prior = update_probability(
|
|
|
|
prior,
|
|
|
|
observation.prob_given_true,
|
|
|
|
observation.prob_given_false,
|
|
|
|
)
|
|
|
|
continue
|
|
|
|
if observation.observed is False:
|
|
|
|
prior = update_probability(
|
|
|
|
prior,
|
|
|
|
1 - observation.prob_given_true,
|
|
|
|
1 - observation.prob_given_false,
|
|
|
|
)
|
|
|
|
continue
|
|
|
|
# observation.observed is None
|
|
|
|
if observation.entity_id is not None:
|
|
|
|
_LOGGER.debug(
|
|
|
|
"Observation for entity '%s' returned None, it will not be used for Bayesian updating",
|
|
|
|
observation.entity_id,
|
|
|
|
)
|
|
|
|
continue
|
|
|
|
_LOGGER.debug(
|
|
|
|
"Observation for template entity returned None rather than a valid boolean, it will not be used for Bayesian updating",
|
|
|
|
)
|
|
|
|
# the prior has been updated and is now the posterior
|
2020-03-31 16:41:29 +00:00
|
|
|
return prior
|
|
|
|
|
2022-10-07 20:23:25 +00:00
|
|
|
def _build_observations_by_entity(self) -> dict[str, list[Observation]]:
|
2020-03-31 16:41:29 +00:00
|
|
|
"""
|
|
|
|
Build and return data structure of the form below.
|
|
|
|
|
|
|
|
{
|
2022-10-04 15:16:39 +00:00
|
|
|
"sensor.sensor1": [Observation, Observation],
|
|
|
|
"sensor.sensor2": [Observation],
|
2020-03-31 16:41:29 +00:00
|
|
|
...
|
|
|
|
}
|
|
|
|
|
|
|
|
Each "observation" must be recognized uniquely, and it should be possible
|
|
|
|
for all relevant observations to be looked up via their `entity_id`.
|
|
|
|
"""
|
|
|
|
|
2022-10-04 15:16:39 +00:00
|
|
|
observations_by_entity: dict[str, list[Observation]] = {}
|
|
|
|
for observation in self._observations:
|
2020-03-31 16:41:29 +00:00
|
|
|
|
2022-10-04 15:16:39 +00:00
|
|
|
if (key := observation.entity_id) is None:
|
2020-08-23 07:59:26 +00:00
|
|
|
continue
|
2022-10-04 15:16:39 +00:00
|
|
|
observations_by_entity.setdefault(key, []).append(observation)
|
2020-08-23 07:59:26 +00:00
|
|
|
|
2022-10-04 15:16:39 +00:00
|
|
|
for entity_observations in observations_by_entity.values():
|
|
|
|
if len(entity_observations) == 1:
|
2022-09-26 02:02:35 +00:00
|
|
|
continue
|
2022-10-04 15:16:39 +00:00
|
|
|
for observation in entity_observations:
|
|
|
|
if observation.platform != "state":
|
2022-09-26 02:02:35 +00:00
|
|
|
continue
|
2022-10-04 15:16:39 +00:00
|
|
|
observation.platform = "multi_state"
|
2020-03-31 16:41:29 +00:00
|
|
|
|
|
|
|
return observations_by_entity
|
2017-08-29 21:53:41 +00:00
|
|
|
|
2022-10-07 20:23:25 +00:00
|
|
|
def _build_observations_by_template(self) -> dict[Template, list[Observation]]:
|
2020-08-23 07:59:26 +00:00
|
|
|
"""
|
|
|
|
Build and return data structure of the form below.
|
|
|
|
|
|
|
|
{
|
2022-10-04 15:16:39 +00:00
|
|
|
"template": [Observation, Observation],
|
|
|
|
"template2": [Observation],
|
2020-08-23 07:59:26 +00:00
|
|
|
...
|
|
|
|
}
|
|
|
|
|
|
|
|
Each "observation" must be recognized uniquely, and it should be possible
|
|
|
|
for all relevant observations to be looked up via their `template`.
|
|
|
|
"""
|
|
|
|
|
2022-10-07 20:23:25 +00:00
|
|
|
observations_by_template: dict[Template, list[Observation]] = {}
|
2022-10-04 15:16:39 +00:00
|
|
|
for observation in self._observations:
|
|
|
|
if observation.value_template is None:
|
2020-08-23 07:59:26 +00:00
|
|
|
continue
|
|
|
|
|
2022-10-04 15:16:39 +00:00
|
|
|
template = observation.value_template
|
|
|
|
observations_by_template.setdefault(template, []).append(observation)
|
2020-08-23 07:59:26 +00:00
|
|
|
|
|
|
|
return observations_by_template
|
|
|
|
|
2022-10-07 20:23:25 +00:00
|
|
|
def _process_numeric_state(self, entity_observation: Observation) -> bool | None:
|
2022-09-26 02:02:35 +00:00
|
|
|
"""Return True if numeric condition is met, return False if not, return None otherwise."""
|
2022-10-04 15:16:39 +00:00
|
|
|
entity = entity_observation.entity_id
|
2017-08-29 21:53:41 +00:00
|
|
|
|
2021-02-08 09:47:57 +00:00
|
|
|
try:
|
2022-09-26 02:02:35 +00:00
|
|
|
if condition.state(self.hass, entity, [STATE_UNKNOWN, STATE_UNAVAILABLE]):
|
|
|
|
return None
|
2021-02-08 09:47:57 +00:00
|
|
|
return condition.async_numeric_state(
|
|
|
|
self.hass,
|
|
|
|
entity,
|
2022-10-04 15:16:39 +00:00
|
|
|
entity_observation.below,
|
|
|
|
entity_observation.above,
|
2021-02-08 09:47:57 +00:00
|
|
|
None,
|
2022-10-04 15:16:39 +00:00
|
|
|
entity_observation.to_dict(),
|
2021-02-08 09:47:57 +00:00
|
|
|
)
|
|
|
|
except ConditionError:
|
2022-09-26 02:02:35 +00:00
|
|
|
return None
|
2017-08-29 21:53:41 +00:00
|
|
|
|
2022-10-07 20:23:25 +00:00
|
|
|
def _process_state(self, entity_observation: Observation) -> bool | None:
|
2022-10-04 15:16:39 +00:00
|
|
|
"""Return True if state conditions are met, return False if they are not.
|
|
|
|
|
|
|
|
Returns None if the state is unavailable.
|
|
|
|
"""
|
|
|
|
|
|
|
|
entity = entity_observation.entity_id
|
2017-08-29 21:53:41 +00:00
|
|
|
|
2021-02-09 08:46:36 +00:00
|
|
|
try:
|
2022-09-26 02:02:35 +00:00
|
|
|
if condition.state(self.hass, entity, [STATE_UNKNOWN, STATE_UNAVAILABLE]):
|
|
|
|
return None
|
|
|
|
|
2022-10-04 15:16:39 +00:00
|
|
|
return condition.state(self.hass, entity, entity_observation.to_state)
|
2021-02-09 08:46:36 +00:00
|
|
|
except ConditionError:
|
2022-09-26 02:02:35 +00:00
|
|
|
return None
|
|
|
|
|
2022-10-07 20:23:25 +00:00
|
|
|
def _process_multi_state(self, entity_observation: Observation) -> bool | None:
|
2022-10-04 15:16:39 +00:00
|
|
|
"""Return True if state conditions are met, otherwise return None.
|
|
|
|
|
|
|
|
Never return False as all other states should have their own probabilities configured.
|
|
|
|
"""
|
|
|
|
|
|
|
|
entity = entity_observation.entity_id
|
2022-09-26 02:02:35 +00:00
|
|
|
|
|
|
|
try:
|
2022-10-04 15:16:39 +00:00
|
|
|
if condition.state(self.hass, entity, entity_observation.to_state):
|
2022-09-26 02:02:35 +00:00
|
|
|
return True
|
|
|
|
except ConditionError:
|
|
|
|
return None
|
2022-10-04 15:16:39 +00:00
|
|
|
return None
|
2019-02-14 00:39:53 +00:00
|
|
|
|
2017-08-29 21:53:41 +00:00
|
|
|
@property
|
2022-10-07 20:23:25 +00:00
|
|
|
def extra_state_attributes(self) -> dict[str, Any]:
|
2017-08-29 21:53:41 +00:00
|
|
|
"""Return the state attributes of the sensor."""
|
2020-04-22 18:25:14 +00:00
|
|
|
|
2017-08-29 21:53:41 +00:00
|
|
|
return {
|
2022-10-04 15:16:39 +00:00
|
|
|
ATTR_PROBABILITY: round(self.probability, 2),
|
|
|
|
ATTR_PROBABILITY_THRESHOLD: self._probability_threshold,
|
|
|
|
# An entity can be in more than one observation so set then list to deduplicate
|
2020-02-25 05:27:59 +00:00
|
|
|
ATTR_OCCURRED_OBSERVATION_ENTITIES: list(
|
2020-04-04 18:05:15 +00:00
|
|
|
{
|
2022-10-04 15:16:39 +00:00
|
|
|
observation.entity_id
|
|
|
|
for observation in self.current_observations.values()
|
|
|
|
if observation is not None
|
|
|
|
and observation.entity_id is not None
|
|
|
|
and observation.observed is not None
|
2020-04-04 18:05:15 +00:00
|
|
|
}
|
2020-02-25 05:27:59 +00:00
|
|
|
),
|
2022-10-04 15:16:39 +00:00
|
|
|
ATTR_OBSERVATIONS: [
|
|
|
|
observation.to_dict()
|
|
|
|
for observation in self.current_observations.values()
|
|
|
|
if observation is not None
|
|
|
|
],
|
2017-08-29 21:53:41 +00:00
|
|
|
}
|
|
|
|
|
2022-08-19 07:54:13 +00:00
|
|
|
async def async_update(self) -> None:
|
2017-08-29 21:53:41 +00:00
|
|
|
"""Get the latest data and update the states."""
|
2020-08-23 07:59:26 +00:00
|
|
|
if not self._callbacks:
|
|
|
|
self._recalculate_and_write_state()
|
|
|
|
return
|
|
|
|
# Force recalc of the templates. The states will
|
|
|
|
# update automatically.
|
|
|
|
for call in self._callbacks:
|
|
|
|
call.async_refresh()
|