platypush/platypush/plugins/sensor/__init__.py

429 lines
15 KiB
Python

from abc import ABC, abstractmethod
from typing import Collection, Iterable, List, Mapping, Optional, Tuple, Type, Union
from typing_extensions import override
from platypush.common.sensors import Numeric, SensorDataType
from platypush.context import get_bus
from platypush.entities import Entity
from platypush.entities.managers.sensors import SensorEntityManager
from platypush.message.event.sensor import (
SensorDataAboveThresholdEvent,
SensorDataBelowThresholdEvent,
SensorDataChangeEvent,
SensorDataEvent,
)
from platypush.plugins import RunnablePlugin, action
from platypush.utils import get_plugin_name_by_class
NoneType = type(None)
ThresholdType = Union[Numeric, Tuple[Numeric, Numeric]]
ThresholdConfiguration = Union[ThresholdType, Mapping[str, ThresholdType]]
class SensorPlugin(RunnablePlugin, SensorEntityManager, ABC):
"""
Sensor abstract plugin. Any plugin that interacts with sensors
should implement this class.
Triggers:
* :class:`platypush.message.event.sensor.SensorDataAboveThresholdEvent`
* :class:`platypush.message.event.sensor.SensorDataBelowThresholdEvent`
* :class:`platypush.message.event.sensor.SensorDataChangeEvent`
"""
_max_retry_secs = 60.0
"""
In case of failure, we apply an exponential back-off retry algorithm. This
is the maximum number of seconds that we should wait during these retries.
"""
def __init__(
self,
thresholds: Optional[ThresholdConfiguration] = None,
tolerance: SensorDataType = 0,
enabled_sensors: Optional[Iterable[str]] = None,
**kwargs,
):
"""
:param thresholds: A number, numeric pair or mapping of ``str`` to
number/numeric pair representing the thresholds for the sensor.
Examples:
.. code-block:: yaml
# Any value below 25 from any sensor will trigger a
# SensorDataBelowThresholdEvent, if the previous value was
# equal or above, and any value above 25 will trigger a
# SensorDataAboveThresholdEvent, if the previous value was
# equal or below
thresholds: 25.0
# Same as above, but the threshold is only applied to
# ``temperature`` readings
thresholds:
temperature: 25.0
# Any value below 20 from any sensor will trigger a
# SensorDataBelowThresholdEvent, if the previous value was
# equal or above, and any value above 25 will trigger a
# SensorDataAboveThresholdEvent, if the previous value was
# equal or below (hysteresis configuration with double
# threshold)
thresholds:
- 20.0
- 25.0
# Same as above, but the threshold is only applied to
# ``temperature`` readings
thresholds:
temperature:
- 20.0
- 25.0
:param tolerance: If set, then the sensor change events will be
triggered only if the difference between the new value and the
previous value is higher than the specified tolerance. For example,
if the sensor data is mapped to a dictionary::
{
"temperature": 0.01, # Tolerance on the 2nd decimal digit
"humidity": 0.1 # Tolerance on the 1st decimal digit
}
Or, if it's a raw scalar number::
0.1 # Tolerance on the 1st decimal digit
Or, if it's a list of values::
[
0.01, # Tolerance on the 2nd decimal digit for the first value
0.1 # Tolerance on the 1st decimal digit for the second value
]
:param enabled_sensors: If :meth:`.get_measurement` returns a key-value
mapping, and ``enabled_sensors`` is set, then only the reported
sensor keys will be returned.
"""
super().__init__(**kwargs)
self._tolerance = tolerance
self._thresholds = thresholds
self._enabled_sensors = set(enabled_sensors or [])
self._last_measurement: Optional[SensorDataType] = None
""" Latest measurement from the sensor. """
def _has_changes_scalar(
self,
old_data: Union[int, float],
new_data: Union[int, float],
attr: Optional[str] = None,
index: Optional[int] = None,
) -> bool:
"""
Returns ``True`` if the new data has changes compared to the old data -
limited to numeric scalar values.
"""
if isinstance(self._tolerance, (int, float)):
tolerance = self._tolerance
elif isinstance(self._tolerance, dict) and attr:
tolerance = self._tolerance.get(attr, 0) # type: ignore
elif isinstance(self._tolerance, (list, tuple)) and index:
tolerance = self._tolerance[index]
else:
tolerance = 0
return abs(old_data - new_data) > tolerance
def _has_changes(
self,
old_data: Optional[SensorDataType],
new_data: Optional[SensorDataType],
attr: Optional[str] = None,
index: Optional[int] = None,
) -> bool:
"""
Returns ``True`` if the new data has changes compared to the old data.
It also applies the configured tolerance thresholds.
"""
# If there is no previous data, then the new data will always be a change
if old_data is None:
return True
# If the new data is missing, then we have no new changes
if new_data is None:
return False
# If the data is scalar, then run the comparison logic
if isinstance(old_data, (int, float)) and isinstance(new_data, (int, float)):
return self._has_changes_scalar(old_data, new_data, attr, index)
# If the data is dict-like, recursively call _has_changes on its attributes
if isinstance(old_data, dict) and isinstance(new_data, dict):
return any(
self._has_changes(old_data.get(attr), value, attr=attr) # type: ignore
for attr, value in new_data.items()
)
# If the data is list-like, recursively call _has_changes on its values
if isinstance(old_data, (list, tuple)) and isinstance(new_data, (list, tuple)):
return any(
self._has_changes(old_data[i], value, index=i)
for i, value in enumerate(new_data)
)
return old_data != new_data
def _process_scalar_threshold_events(
self,
old_data: Optional[Numeric],
new_data: Numeric,
attr: Optional[str] = None,
) -> List[SensorDataEvent]:
"""
Inner scalar processing for sensor above/below threshold events.
"""
event_types: List[Type[SensorDataEvent]] = []
event_args = {
'source': get_plugin_name_by_class(self.__class__),
}
# If we're mapping against a dict attribute, extract its thresholds,
# otherwise use the default configured thresholds
thresholds = (
self._thresholds.get(attr)
if attr and isinstance(self._thresholds, dict)
else self._thresholds
)
# Normalize low/high thresholds
low_t, high_t = (
sorted(thresholds[:2])
if isinstance(thresholds, (list, tuple))
else (thresholds, thresholds)
)
if low_t is None or high_t is None:
return []
assert isinstance(low_t, (int, float)) and isinstance(
high_t, (int, float)
), f'Non-numeric thresholds detected: "{low_t}" and "{high_t}"'
# Above threshold case
if (old_data is None or old_data <= high_t) and new_data > high_t:
event_types.append(SensorDataAboveThresholdEvent)
# Below threshold case
elif (old_data is None or old_data >= low_t) and new_data < low_t:
event_types.append(SensorDataBelowThresholdEvent)
return [
event_type(
data={attr: new_data} if attr else new_data,
**event_args,
)
for event_type in event_types
]
def _process_threshold_events(
self,
old_data: Optional[SensorDataType],
new_data: SensorDataType,
attr: Optional[str] = None,
) -> List[SensorDataEvent]:
"""
Processes sensor above/below threshold events.
"""
events: List[SensorDataEvent] = []
# If there are no configured thresholds, there's nothing to do
if self._thresholds in (None, {}, (), []):
return events
# Scalar case
if isinstance(old_data, (int, float, NoneType)) and isinstance(
new_data, (int, float)
):
return self._process_scalar_threshold_events(
old_data, new_data, attr # type: ignore
)
# From here on, threshold comparison only applies if both the old and
# new data is a str -> number mapping
if not (isinstance(old_data, (dict, NoneType)) and isinstance(new_data, dict)):
return events
# Recursively call _process_threshold_events on the data attributes
for attr, value in new_data.items(): # type: ignore
events.extend(
self._process_threshold_events(
old_data=(
old_data.get(attr) # type: ignore
if isinstance(old_data, dict)
else old_data
),
new_data=value,
attr=str(attr),
)
)
return events
def _process_sensor_events(
self, old_data: Optional[SensorDataType], new_data: Optional[SensorDataType]
) -> bool:
"""
Given the previous and new measurement, it runs the comparison logic
against the configured tolerance values and thresholds, and it
processes the required sensor data change and above/below threshold
events.
:return: ``True`` if some events were processed for the new data,
``False`` otherwise.
"""
# If the new data is missing or there are no changes, there are no
# events to process
if new_data is None or not self._has_changes(old_data, new_data):
return False
events = [
SensorDataChangeEvent(
data=new_data, # type: ignore
source=get_plugin_name_by_class(self.__class__),
),
*self._process_threshold_events(old_data, new_data),
]
for event in events:
get_bus().post(event)
self.publish_entities(new_data)
return True
def _update_last_measurement(self, new_data: SensorDataType):
"""
Update the ``_last_measurement`` attribute with the newly acquired data.
"""
# If there is no last measurement, or either the new or old
# measurements are not dictionaries, then overwrite the previous data
# with the new data
if not (
isinstance(self._last_measurement, dict) and isinstance(new_data, dict)
):
self._last_measurement = new_data
# Otherwise, merge the old data with the new
self._last_measurement.update(new_data) # type: ignore
def _filter_enabled_sensors(self, data: SensorDataType) -> SensorDataType:
"""
If ``data`` is a sensor mapping, and ``enabled_sensors`` is set, filter
out only the requested keys.
"""
if not (isinstance(data, dict) and self._enabled_sensors):
return data
return {k: v for k, v in data.items() if k in self._enabled_sensors}
@override
@abstractmethod
def transform_entities(self, entities: SensorDataType) -> Collection[Entity]:
raise NotImplementedError()
@override
def publish_entities(
self, entities: SensorDataType, *args, **kwargs
) -> Collection[Entity]:
# Skip empty data
if (
entities is None
or (isinstance(entities, dict) and not entities)
or (isinstance(entities, (list, tuple)) and not entities)
):
return []
return super().publish_entities(entities, *args, **kwargs) # type: ignore
@abstractmethod
@action
def get_measurement(self, *args, **kwargs) -> SensorDataType:
"""
Implemented by the subclasses.
:returns: Either a raw scalar::
output = 273.16
or a name-value dictionary with the values that have been read::
output = {
"temperature": 21.5,
"humidity": 41.0
}
or a list of values::
output = [
0.01,
0.34,
0.53,
...
]
"""
raise NotImplementedError()
@action
def get_data(self, *args, **kwargs):
"""
(Deprecated) alias for :meth:`.get_measurement`
"""
return self.get_measurement(*args, **kwargs)
@action
def status(self, *_, **__) -> Optional[SensorDataType]:
"""
Returns the latest read values and publishes the
:class:`platypush.message.event.entities.EntityUpdateEvent` events if
required.
"""
if self._last_measurement is not None:
self.publish_entities(self._last_measurement)
return self._last_measurement
@override
def main(self):
sleep_retry_secs = 1 # Exponential back-off
while not self.should_stop():
try:
new_data: SensorDataType = self.get_measurement().output # type: ignore
# Reset the exponential back-off retry counter in case of success
sleep_retry_secs = 1
except Exception as e:
self.logger.warning(
'Could not update the status: %s. Next retry in %d seconds',
e,
sleep_retry_secs,
)
self.logger.exception(e)
self.wait_stop(sleep_retry_secs)
sleep_retry_secs = min(
sleep_retry_secs * 2,
self._max_retry_secs,
)
continue
new_data = self._filter_enabled_sensors(new_data)
is_event_processed = self._process_sensor_events(
self._last_measurement, new_data
)
if is_event_processed:
self._update_last_measurement(new_data)
self.wait_stop(self.poll_interval)
# vim:sw=4:ts=4:et: