2018-01-10 03:14:27 +01:00
|
|
|
import copy
|
2017-12-20 20:25:08 +01:00
|
|
|
import json
|
2022-12-11 11:39:38 +01:00
|
|
|
import logging
|
2022-09-05 03:05:22 +02:00
|
|
|
import random
|
2023-04-27 22:07:02 +02:00
|
|
|
import re
|
2018-10-08 12:35:56 +02:00
|
|
|
import time
|
2017-12-20 20:25:08 +01:00
|
|
|
|
2023-04-27 22:07:02 +02:00
|
|
|
from dataclasses import dataclass, field
|
2018-01-10 03:14:27 +01:00
|
|
|
from datetime import date
|
2023-04-27 22:07:02 +02:00
|
|
|
from typing import Any
|
2018-01-10 03:14:27 +01:00
|
|
|
|
2017-12-24 01:03:26 +01:00
|
|
|
from platypush.config import Config
|
2017-12-20 20:25:08 +01:00
|
|
|
from platypush.message import Message
|
2017-12-24 01:03:26 +01:00
|
|
|
from platypush.utils import get_event_class_by_type
|
2017-12-20 20:25:08 +01:00
|
|
|
|
2022-12-11 11:39:38 +01:00
|
|
|
logger = logging.getLogger('platypush')
|
|
|
|
|
2019-12-22 19:09:02 +01:00
|
|
|
|
2017-12-20 20:25:08 +01:00
|
|
|
class Event(Message):
|
2022-08-04 01:04:00 +02:00
|
|
|
"""Event message class"""
|
2017-12-20 20:25:08 +01:00
|
|
|
|
2019-03-06 02:01:17 +01:00
|
|
|
# If this class property is set to false then the logging of these events
|
|
|
|
# will be disabled. Logging is usually disabled for events with a very
|
|
|
|
# high frequency that would otherwise pollute the logs e.g. camera capture
|
|
|
|
# events
|
2022-02-07 15:45:19 +01:00
|
|
|
# pylint: disable=redefined-builtin
|
2022-08-04 01:04:00 +02:00
|
|
|
def __init__(
|
|
|
|
self,
|
|
|
|
target=None,
|
|
|
|
origin=None,
|
|
|
|
id=None,
|
|
|
|
timestamp=None,
|
2022-12-11 11:39:38 +01:00
|
|
|
logging_level=logging.INFO,
|
2022-08-04 01:04:00 +02:00
|
|
|
disable_web_clients_notification=False,
|
2023-04-26 01:45:58 +02:00
|
|
|
**kwargs,
|
2022-08-04 01:04:00 +02:00
|
|
|
):
|
2017-12-20 20:25:08 +01:00
|
|
|
"""
|
2023-03-26 22:53:11 +02:00
|
|
|
:param target: Target node
|
|
|
|
:type target: str
|
|
|
|
:param origin: Origin node (default: current node)
|
|
|
|
:type origin: str
|
|
|
|
:param id: Event ID (default: auto-generated)
|
|
|
|
:type id: str
|
|
|
|
:param timestamp: Event timestamp (default: current time)
|
|
|
|
:type timestamp: float
|
|
|
|
:param logging_level: Logging level for this event (default:
|
|
|
|
``logging.INFO``)
|
|
|
|
:param disable_web_clients_notification: Don't send a notification of
|
|
|
|
this event to the websocket clients
|
|
|
|
:param kwargs: Additional arguments for the event
|
2017-12-20 20:25:08 +01:00
|
|
|
"""
|
|
|
|
|
2022-12-11 11:39:38 +01:00
|
|
|
super().__init__(timestamp=timestamp, logging_level=logging_level)
|
2017-12-20 20:25:08 +01:00
|
|
|
self.id = id if id else self._generate_id()
|
2017-12-24 01:03:26 +01:00
|
|
|
self.target = target if target else Config.get('device_id')
|
|
|
|
self.origin = origin if origin else Config.get('device_id')
|
2023-04-26 01:45:58 +02:00
|
|
|
self.type = f'{self.__class__.__module__}.{self.__class__.__name__}'
|
2017-12-20 20:25:08 +01:00
|
|
|
self.args = kwargs
|
2019-12-22 19:38:01 +01:00
|
|
|
self.disable_web_clients_notification = disable_web_clients_notification
|
2023-07-15 01:01:15 +02:00
|
|
|
self._logger = logging.getLogger('platypush:events')
|
2017-12-20 20:25:08 +01:00
|
|
|
|
2019-12-22 19:09:02 +01:00
|
|
|
for arg, value in self.args.items():
|
2021-11-14 19:43:19 +01:00
|
|
|
if arg not in [
|
2022-08-04 01:04:00 +02:00
|
|
|
'id',
|
|
|
|
'args',
|
|
|
|
'origin',
|
|
|
|
'target',
|
|
|
|
'type',
|
|
|
|
'timestamp',
|
2022-12-11 11:39:38 +01:00
|
|
|
'logging_level',
|
2021-11-14 19:43:19 +01:00
|
|
|
] and not arg.startswith('_'):
|
2020-05-09 01:47:12 +02:00
|
|
|
self.__setattr__(arg, value)
|
2019-12-22 19:09:02 +01:00
|
|
|
|
2017-12-20 20:25:08 +01:00
|
|
|
@classmethod
|
|
|
|
def build(cls, msg):
|
2023-04-26 01:45:58 +02:00
|
|
|
"""
|
|
|
|
Builds an event message from a JSON UTF-8 string/bytearray, a
|
|
|
|
dictionary, or another Event
|
|
|
|
"""
|
2017-12-20 20:25:08 +01:00
|
|
|
|
|
|
|
msg = super().parse(msg)
|
|
|
|
event_type = msg['args'].pop('type')
|
2017-12-24 01:03:26 +01:00
|
|
|
event_class = get_event_class_by_type(event_type)
|
2017-12-20 20:25:08 +01:00
|
|
|
|
2018-01-02 00:35:55 +01:00
|
|
|
args = msg['args'] if 'args' in msg else {}
|
2017-12-20 20:25:08 +01:00
|
|
|
args['id'] = msg['id'] if 'id' in msg else cls._generate_id()
|
2018-01-02 00:35:55 +01:00
|
|
|
args['target'] = msg['target'] if 'target' in msg else Config.get('device_id')
|
|
|
|
args['origin'] = msg['origin'] if 'origin' in msg else Config.get('device_id')
|
2018-10-08 12:35:56 +02:00
|
|
|
args['timestamp'] = msg['_timestamp'] if '_timestamp' in msg else time.time()
|
2017-12-20 20:25:08 +01:00
|
|
|
return event_class(**args)
|
|
|
|
|
2018-01-02 00:35:55 +01:00
|
|
|
@staticmethod
|
|
|
|
def _generate_id():
|
2022-08-04 01:04:00 +02:00
|
|
|
"""Generate a unique event ID"""
|
2023-04-26 01:45:58 +02:00
|
|
|
return ''.join([f'{random.randint(0, 255):02x}' for _ in range(16)])
|
|
|
|
|
2023-04-27 22:07:02 +02:00
|
|
|
@staticmethod
|
|
|
|
def _is_relational_filter(filter: dict) -> bool:
|
|
|
|
"""
|
|
|
|
Check if a condition is a relational filter.
|
|
|
|
|
|
|
|
For a condition to be a relational filter, it must have at least one
|
|
|
|
key starting with `$`.
|
|
|
|
"""
|
|
|
|
if not isinstance(filter, dict):
|
|
|
|
return False
|
|
|
|
return any(key.startswith('$') for key in filter)
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
def __relational_filter_matches(filter: dict, value: Any) -> bool:
|
|
|
|
"""
|
|
|
|
Return True if the conditions in the filter match the given event
|
|
|
|
arguments.
|
|
|
|
"""
|
|
|
|
for op, filter_val in filter.items():
|
|
|
|
comparator = _event_filter_operators.get(op)
|
|
|
|
assert comparator, f'Invalid operator: {op}'
|
|
|
|
|
|
|
|
# If this is a numeric or string filter, and one of the two values
|
|
|
|
# is null, return False - it doesn't make sense to run numeric or
|
|
|
|
# string comparison with null values.
|
|
|
|
if (op in _numeric_filter_operators or op in _string_filter_operators) and (
|
|
|
|
filter_val is None or value is None
|
|
|
|
):
|
|
|
|
return False
|
|
|
|
|
|
|
|
# If this is a numeric-only or string-only filter, then the
|
|
|
|
# operands' types should be consistent with the operator.
|
|
|
|
if op in _numeric_filter_operators:
|
|
|
|
try:
|
|
|
|
value = float(value)
|
|
|
|
filter_val = float(filter_val)
|
|
|
|
except (ValueError, TypeError) as e:
|
|
|
|
raise AssertionError(
|
|
|
|
f'Could not convert either "{value}" nor "{filter_val} to a number'
|
|
|
|
) from e
|
|
|
|
elif op in _string_filter_operators:
|
|
|
|
assert isinstance(filter_val, str) and isinstance(value, str), (
|
|
|
|
f'Expected two strings, got "{filter_val}" '
|
|
|
|
f'({type(filter_val)}) and "{value}" ({type(value)})'
|
|
|
|
)
|
|
|
|
|
|
|
|
if not comparator(value, filter_val):
|
|
|
|
return False
|
|
|
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
@classmethod
|
|
|
|
def _relational_filter_matches(cls, filter: dict, value: Any) -> bool:
|
|
|
|
is_match = False
|
|
|
|
try:
|
|
|
|
is_match = cls.__relational_filter_matches(filter, value)
|
|
|
|
except AssertionError as e:
|
|
|
|
logger.error('Invalid filter: %s', e)
|
|
|
|
|
|
|
|
if not is_match:
|
|
|
|
return False
|
|
|
|
|
|
|
|
return True
|
|
|
|
|
2023-04-28 11:04:33 +02:00
|
|
|
# pylint: disable=too-many-branches,too-many-return-statements
|
2023-04-26 01:45:58 +02:00
|
|
|
def _matches_condition(
|
|
|
|
self,
|
|
|
|
condition: dict,
|
2023-04-28 11:04:33 +02:00
|
|
|
event_args: dict,
|
2023-04-26 01:45:58 +02:00
|
|
|
result: "EventMatchResult",
|
|
|
|
match_scores: list,
|
|
|
|
) -> bool:
|
2023-04-28 11:04:33 +02:00
|
|
|
for attr, condition_value in condition.items():
|
|
|
|
if attr not in event_args:
|
2023-04-26 01:45:58 +02:00
|
|
|
return False
|
|
|
|
|
2023-04-28 11:04:33 +02:00
|
|
|
event_value = event_args[attr]
|
|
|
|
if isinstance(event_value, str):
|
|
|
|
if self._is_relational_filter(condition_value):
|
|
|
|
if not self._relational_filter_matches(
|
|
|
|
condition_value, event_value
|
|
|
|
):
|
2023-04-27 22:07:02 +02:00
|
|
|
return False
|
2023-04-26 01:45:58 +02:00
|
|
|
else:
|
2023-04-27 22:07:02 +02:00
|
|
|
self._matches_argument(
|
2023-04-28 11:04:33 +02:00
|
|
|
argname=attr,
|
|
|
|
condition_value=condition_value,
|
|
|
|
event_args=event_args,
|
|
|
|
result=result,
|
2023-04-27 22:07:02 +02:00
|
|
|
)
|
|
|
|
|
|
|
|
if result.is_match:
|
|
|
|
match_scores.append(result.score)
|
|
|
|
else:
|
|
|
|
return False
|
2023-04-28 11:04:33 +02:00
|
|
|
elif isinstance(condition_value, dict):
|
|
|
|
if self._is_relational_filter(condition_value):
|
|
|
|
if not self._relational_filter_matches(
|
|
|
|
condition_value, event_value
|
|
|
|
):
|
2023-04-27 22:07:02 +02:00
|
|
|
return False
|
|
|
|
else:
|
2023-04-28 11:04:33 +02:00
|
|
|
if not isinstance(event_value, dict):
|
2023-04-27 22:07:02 +02:00
|
|
|
return False
|
|
|
|
|
|
|
|
if not self._matches_condition(
|
2023-04-28 11:04:33 +02:00
|
|
|
condition=condition_value,
|
|
|
|
event_args=event_value,
|
2023-04-27 22:07:02 +02:00
|
|
|
result=result,
|
|
|
|
match_scores=match_scores,
|
|
|
|
):
|
|
|
|
return False
|
2023-04-28 11:04:33 +02:00
|
|
|
else:
|
|
|
|
if event_value != condition_value:
|
|
|
|
return False
|
|
|
|
|
|
|
|
match_scores.append(2.0)
|
2023-04-26 01:45:58 +02:00
|
|
|
|
|
|
|
return True
|
2018-01-02 00:35:55 +01:00
|
|
|
|
2017-12-24 01:03:26 +01:00
|
|
|
def matches_condition(self, condition):
|
|
|
|
"""
|
2017-12-24 13:15:37 +01:00
|
|
|
If the event matches an event condition, it will return an EventMatchResult
|
2020-01-09 23:40:59 +01:00
|
|
|
:param condition: The platypush.event.hook.EventCondition object
|
2017-12-24 01:03:26 +01:00
|
|
|
"""
|
|
|
|
|
2018-01-02 00:35:55 +01:00
|
|
|
result = EventMatchResult(is_match=False, parsed_args=self.args)
|
|
|
|
match_scores = []
|
|
|
|
|
2019-12-22 19:09:02 +01:00
|
|
|
if not isinstance(self, condition.type):
|
|
|
|
return result
|
2017-12-24 01:03:26 +01:00
|
|
|
|
2023-04-26 01:45:58 +02:00
|
|
|
if not self._matches_condition(
|
|
|
|
condition=condition.args,
|
2023-04-28 11:04:33 +02:00
|
|
|
event_args=self.args,
|
2023-04-26 01:45:58 +02:00
|
|
|
result=result,
|
|
|
|
match_scores=match_scores,
|
|
|
|
):
|
|
|
|
return result
|
2017-12-24 01:03:26 +01:00
|
|
|
|
2017-12-24 13:15:37 +01:00
|
|
|
result.is_match = True
|
2018-01-02 00:35:55 +01:00
|
|
|
if match_scores:
|
|
|
|
result.score = sum(match_scores) / float(len(match_scores))
|
|
|
|
|
2017-12-24 13:15:37 +01:00
|
|
|
return result
|
2017-12-24 01:03:26 +01:00
|
|
|
|
2023-04-26 01:45:58 +02:00
|
|
|
def _matches_argument(
|
2023-04-28 11:04:33 +02:00
|
|
|
self, argname, condition_value, event_args, result: "EventMatchResult"
|
2023-04-26 01:45:58 +02:00
|
|
|
):
|
2018-01-02 00:35:55 +01:00
|
|
|
"""
|
|
|
|
Returns an EventMatchResult if the event argument [argname] matches
|
|
|
|
[condition_value].
|
|
|
|
"""
|
|
|
|
|
2023-04-26 01:45:58 +02:00
|
|
|
# Simple equality match by default. It can be overridden by the derived classes.
|
2023-04-28 11:04:33 +02:00
|
|
|
result.is_match = event_args.get(argname) == condition_value
|
2023-04-26 01:45:58 +02:00
|
|
|
if result.is_match:
|
|
|
|
result.score += 2
|
|
|
|
else:
|
|
|
|
result.score = 0
|
2018-01-02 00:35:55 +01:00
|
|
|
|
2023-12-21 00:28:27 +01:00
|
|
|
return result
|
|
|
|
|
2024-04-09 00:15:51 +02:00
|
|
|
def as_dict(self):
|
|
|
|
"""
|
|
|
|
Converts the event into a dictionary
|
|
|
|
"""
|
|
|
|
args = copy.deepcopy(self.args)
|
|
|
|
flatten(args)
|
|
|
|
return {
|
|
|
|
'type': 'event',
|
|
|
|
'target': self.target,
|
|
|
|
'origin': self.origin if hasattr(self, 'origin') else None,
|
|
|
|
'id': self.id if hasattr(self, 'id') else None,
|
|
|
|
'_timestamp': self.timestamp,
|
|
|
|
'args': {'type': self.type, **args},
|
|
|
|
}
|
|
|
|
|
2017-12-20 20:25:08 +01:00
|
|
|
def __str__(self):
|
|
|
|
"""
|
|
|
|
Overrides the str() operator and converts
|
|
|
|
the message into a UTF-8 JSON string
|
|
|
|
"""
|
2018-01-10 03:14:27 +01:00
|
|
|
args = copy.deepcopy(self.args)
|
|
|
|
flatten(args)
|
2024-04-09 00:15:51 +02:00
|
|
|
return json.dumps(self.as_dict(), cls=self.Encoder)
|
2017-12-20 20:25:08 +01:00
|
|
|
|
|
|
|
|
2023-04-27 22:07:02 +02:00
|
|
|
@dataclass
|
2022-08-04 01:04:00 +02:00
|
|
|
class EventMatchResult:
|
2023-04-26 01:45:58 +02:00
|
|
|
"""
|
|
|
|
When comparing an event against an event condition, you want to
|
2022-08-04 01:04:00 +02:00
|
|
|
return this object. It contains the match status (True or False),
|
|
|
|
any parsed arguments, and a match_score that identifies how "strong"
|
|
|
|
the match is - in case of multiple event matches, the ones with the
|
2023-04-26 01:45:58 +02:00
|
|
|
highest score will win.
|
|
|
|
"""
|
2017-12-24 13:15:37 +01:00
|
|
|
|
2023-04-27 22:07:02 +02:00
|
|
|
is_match: bool
|
|
|
|
score: float = 0.0
|
|
|
|
parsed_args: dict = field(default_factory=dict)
|
2017-12-24 13:15:37 +01:00
|
|
|
|
|
|
|
|
2018-01-10 03:14:27 +01:00
|
|
|
def flatten(args):
|
2023-04-26 01:45:58 +02:00
|
|
|
"""
|
|
|
|
Flatten a nested dictionary for string serialization.
|
|
|
|
"""
|
2018-01-10 03:14:27 +01:00
|
|
|
if isinstance(args, dict):
|
2023-03-26 22:53:11 +02:00
|
|
|
for key, value in args.items():
|
2018-01-10 03:14:27 +01:00
|
|
|
if isinstance(value, date):
|
|
|
|
args[key] = value.isoformat()
|
2022-02-07 15:45:19 +01:00
|
|
|
elif isinstance(value, (dict, list)):
|
2018-01-10 03:14:27 +01:00
|
|
|
flatten(args[key])
|
|
|
|
elif isinstance(args, list):
|
2022-02-07 15:45:19 +01:00
|
|
|
for i, arg in enumerate(args):
|
|
|
|
if isinstance(arg, date):
|
|
|
|
args[i] = arg.isoformat()
|
|
|
|
elif isinstance(arg, (dict, list)):
|
2018-01-10 03:14:27 +01:00
|
|
|
flatten(args[i])
|
|
|
|
|
2022-08-04 01:04:00 +02:00
|
|
|
|
2023-04-27 22:07:02 +02:00
|
|
|
_event_filter_operators = {
|
|
|
|
'$gt': lambda a, b: a > b,
|
|
|
|
'$gte': lambda a, b: a >= b,
|
|
|
|
'$lt': lambda a, b: a < b,
|
|
|
|
'$lte': lambda a, b: a <= b,
|
|
|
|
'$eq': lambda a, b: a == b,
|
|
|
|
'$ne': lambda a, b: a != b,
|
|
|
|
'$regex': lambda a, b: re.search(b, a),
|
|
|
|
}
|
|
|
|
|
|
|
|
_numeric_filter_operators = {'$gt', '$gte', '$lt', '$lte'}
|
|
|
|
|
|
|
|
_string_filter_operators = {'$regex'}
|
|
|
|
|
|
|
|
|
2017-12-20 20:25:08 +01:00
|
|
|
# vim:sw=4:ts=4:et:
|