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: