From 371dd6da0ae2ba14debbba85d12d6e3ed6a0d597 Mon Sep 17 00:00:00 2001 From: Fabio Manganiello Date: Thu, 22 Jul 2021 01:02:15 +0200 Subject: [PATCH] Added `sun` plugin [closes #194] --- CHANGELOG.md | 6 ++ docs/source/events.rst | 1 + docs/source/platypush/events/sun.rst | 5 ++ docs/source/platypush/plugins/sun.rst | 5 ++ docs/source/plugins.rst | 1 + platypush/__init__.py | 14 ++++- platypush/context/__init__.py | 12 ++++ platypush/message/event/sun.py | 36 +++++++++++ platypush/plugins/__init__.py | 59 +++++++++++++++++- platypush/plugins/sun.py | 87 +++++++++++++++++++++++++++ platypush/schemas/__init__.py | 10 +++ platypush/schemas/spotify.py | 11 +--- platypush/schemas/sun.py | 14 +++++ 13 files changed, 248 insertions(+), 13 deletions(-) create mode 100644 docs/source/platypush/events/sun.rst create mode 100644 docs/source/platypush/plugins/sun.rst create mode 100644 platypush/message/event/sun.py create mode 100644 platypush/plugins/sun.py create mode 100644 platypush/schemas/sun.py diff --git a/CHANGELOG.md b/CHANGELOG.md index d5e49f2c0..01f3837dd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,12 @@ All notable changes to this project will be documented in this file. Given the high speed of development in the first phase, changes are being reported only starting from v0.20.2. +## [Unreleased] + +### Added + +- Added `sun` plugin for sunrise/sunset events. + ## [0.21.2] - 2021-07-20 ### Added diff --git a/docs/source/events.rst b/docs/source/events.rst index a96548743..b0bace683 100644 --- a/docs/source/events.rst +++ b/docs/source/events.rst @@ -54,6 +54,7 @@ Events platypush/events/serial.rst platypush/events/sound.rst platypush/events/stt.rst + platypush/events/sun.rst platypush/events/tensorflow.rst platypush/events/todoist.rst platypush/events/torrent.rst diff --git a/docs/source/platypush/events/sun.rst b/docs/source/platypush/events/sun.rst new file mode 100644 index 000000000..135e60d25 --- /dev/null +++ b/docs/source/platypush/events/sun.rst @@ -0,0 +1,5 @@ +``platypush.message.event.sun`` +=============================== + +.. automodule:: platypush.message.event.sun + :members: diff --git a/docs/source/platypush/plugins/sun.rst b/docs/source/platypush/plugins/sun.rst new file mode 100644 index 000000000..716ff5c12 --- /dev/null +++ b/docs/source/platypush/plugins/sun.rst @@ -0,0 +1,5 @@ +``sun`` +======= + +.. automodule:: platypush.plugins.sun + :members: diff --git a/docs/source/plugins.rst b/docs/source/plugins.rst index ae49c3919..72570625f 100644 --- a/docs/source/plugins.rst +++ b/docs/source/plugins.rst @@ -119,6 +119,7 @@ Plugins platypush/plugins/stt.deepspeech.rst platypush/plugins/stt.picovoice.hotword.rst platypush/plugins/stt.picovoice.speech.rst + platypush/plugins/sun.rst platypush/plugins/switch.rst platypush/plugins/switch.tplink.rst platypush/plugins/switch.wemo.rst diff --git a/platypush/__init__.py b/platypush/__init__.py index be88565ae..c8edf8339 100644 --- a/platypush/__init__.py +++ b/platypush/__init__.py @@ -12,7 +12,7 @@ import sys from .bus.redis import RedisBus from .config import Config -from .context import register_backends +from .context import register_backends, register_plugins from .cron.scheduler import CronScheduler from .event.processor import EventProcessor from .logger import Logger @@ -20,8 +20,7 @@ from .message.event import Event from .message.event.application import ApplicationStartedEvent from .message.request import Request from .message.response import Response -from .utils import set_thread_name - +from .utils import set_thread_name, get_enabled_plugins __author__ = 'Fabio Manganiello ' __version__ = '0.21.2' @@ -160,9 +159,15 @@ class Daemon: def stop_app(self): """ Stops the backends and the bus """ + from .plugins import RunnablePlugin + for backend in self.backends.values(): backend.stop() + for plugin in get_enabled_plugins().values(): + if isinstance(plugin, RunnablePlugin): + plugin.stop() + self.bus.stop() if self.cron_scheduler: self.cron_scheduler.stop() @@ -184,6 +189,9 @@ class Daemon: for backend in self.backends.values(): backend.start() + # Initialize the plugins + register_plugins(bus=self.bus) + # Start the cron scheduler if Config.get_cronjobs(): self.cron_scheduler = CronScheduler(jobs=Config.get_cronjobs()) diff --git a/platypush/context/__init__.py b/platypush/context/__init__.py index ecb542d4a..16048fead 100644 --- a/platypush/context/__init__.py +++ b/platypush/context/__init__.py @@ -5,6 +5,7 @@ import logging from threading import RLock from ..config import Config +from ..utils import get_enabled_plugins logger = logging.getLogger('platypush:context') @@ -21,6 +22,7 @@ plugins_init_locks = {} # Reference to the main application bus main_bus = None + def register_backends(bus=None, global_scope=False, **kwargs): """ Initialize the backend objects based on the configuration and returns a name -> backend_instance map. @@ -59,6 +61,16 @@ def register_backends(bus=None, global_scope=False, **kwargs): return backends + +def register_plugins(bus=None): + from ..plugins import RunnablePlugin + + for plugin in get_enabled_plugins().values(): + if isinstance(plugin, RunnablePlugin): + plugin.bus = bus + plugin.start() + + def get_backend(name): """ Returns the backend instance identified by name if it exists """ diff --git a/platypush/message/event/sun.py b/platypush/message/event/sun.py new file mode 100644 index 000000000..46755123d --- /dev/null +++ b/platypush/message/event/sun.py @@ -0,0 +1,36 @@ +from datetime import datetime +from typing import Optional + +from platypush.message.event import Event + + +class SunEvent(Event): + """ + Base class for sun related events (sunrise and sunset). + """ + def __init__(self, latitude: Optional[float] = None, longitude: Optional[float] = None, + time: Optional[datetime] = None, *args, **kwargs): + """ + :param latitude: Latitude for the sun event. + :param longitude: Longitude for the sun event. + :param time: Event timestamp. + """ + super().__init__(*args, latitude=latitude, longitude=longitude, time=time, **kwargs) + self.latitude = latitude + self.longitude = longitude + self.time = time + + +class SunriseEvent(SunEvent): + """ + Class for sunrise events. + """ + + +class SunsetEvent(SunEvent): + """ + Class for sunset events. + """ + + +# vim:sw=4:ts=4:et: diff --git a/platypush/plugins/__init__.py b/platypush/plugins/__init__.py index 0db4cadf1..fd0042aa7 100644 --- a/platypush/plugins/__init__.py +++ b/platypush/plugins/__init__.py @@ -1,10 +1,15 @@ import logging +import threading +import time +from abc import ABC from functools import wraps +from typing import Optional +from platypush.bus import Bus from platypush.event import EventGenerator from platypush.message.response import Response -from platypush.utils import get_decorators, get_plugin_name_by_class +from platypush.utils import get_decorators, get_plugin_name_by_class, set_thread_name def action(f): @@ -53,4 +58,56 @@ class Plugin(EventGenerator): return getattr(self, method)(*args, **kwargs) +class RunnablePlugin(ABC, Plugin): + """ + Class for runnable plugins - i.e. plugins that have a start/stop method and can be started. + """ + def __init__(self, poll_interval: Optional[float] = None, **kwargs): + """ + :param poll_interval: How often the :meth:`.loop` function should be execute (default: None, no pause/interval). + """ + super().__init__(**kwargs) + self.poll_interval = poll_interval + self.bus: Optional[Bus] = None + self._should_stop = threading.Event() + self._thread: Optional[threading.Thread] = None + + def main(self): + raise NotImplementedError() + + def should_stop(self): + return self._should_stop.is_set() + + def start(self): + set_thread_name(self.__class__.__name__) + self._thread = threading.Thread(target=self._runner) + self._thread.start() + + def stop(self): + self._should_stop.set() + if self._thread and self._thread.is_alive(): + self.logger.info(f'Waiting for {self.__class__.__name__} to stop') + # noinspection PyBroadException + try: + self._thread.join() + except: + pass + + self.logger.info(f'{self.__class__.__name__} stopped') + + def _runner(self): + self.logger.info(f'Starting {self.__class__.__name__}') + + while not self.should_stop(): + try: + self.main() + except Exception as e: + self.logger.exception(e) + + if self.poll_interval: + time.sleep(self.poll_interval) + + self._thread = None + + # vim:sw=4:ts=4:et: diff --git a/platypush/plugins/sun.py b/platypush/plugins/sun.py new file mode 100644 index 000000000..d757eb4c9 --- /dev/null +++ b/platypush/plugins/sun.py @@ -0,0 +1,87 @@ +import datetime +import time +from typing import Optional + +import requests +from dateutil.tz import gettz, tzutc + +from platypush.message.event.sun import SunriseEvent, SunsetEvent +from platypush.plugins import RunnablePlugin, action +from platypush.schemas.sun import SunEventsSchema + + +class SunPlugin(RunnablePlugin): + """ + Plugin to get sunset/sunrise events and info for a certain location. + + Triggers: + + * :class:`platypush.message.event.sun.SunriseEvent` on sunrise. + * :class:`platypush.message.event.sun.SunsetEvent` on sunset. + + """ + _base_url = 'https://api.sunrise-sunset.org/json' + _attr_to_event_class = { + 'sunrise': SunriseEvent, + 'sunset': SunsetEvent, + } + + def __init__(self, latitude: float, longitude: float, **kwargs): + """ + :param latitude: Default latitude. + :param longitude: Default longitude. + """ + super().__init__(**kwargs) + self.latitude = latitude + self.longitude = longitude + + def main(self): + while not self.should_stop(): + # noinspection PyUnresolvedReferences + next_events = self.get_events().output + next_events = sorted([ + event_class(latitude=self.latitude, longitude=self.longitude, time=next_events[attr]) + for attr, event_class in self._attr_to_event_class.items() + if next_events.get(attr) + ], key=lambda t: t.time) + + for event in next_events: + # noinspection PyTypeChecker + dt = datetime.datetime.fromisoformat(event.time) + while (not self.should_stop()) and (dt > datetime.datetime.now(tz=gettz())): + time.sleep(1) + + if dt <= datetime.datetime.now(tz=gettz()): + self.bus.post(event) + + @staticmethod + def _convert_time(t: str) -> datetime.datetime: + now = datetime.datetime.now().replace(tzinfo=gettz()) # lgtm [py/call-to-non-callable] + dt = datetime.datetime.strptime(t, '%H:%M:%S %p') + dt = datetime.datetime(year=now.year, month=now.month, day=now.day, + hour=dt.hour, minute=dt.minute, second=dt.second, tzinfo=tzutc()) + + if dt < now: + dt += datetime.timedelta(days=1) + return datetime.datetime.fromtimestamp(dt.timestamp(), tz=gettz()) + + @action + def get_events(self, latitude: Optional[float] = None, longitude: Optional[float] = None) -> dict: + """ + Return the next sun events. + + :param latitude: Default latitude override. + :param longitude: Default longitude override. + :return: .. schema:: sun.SunEventsSchema + """ + response = requests.get(self._base_url, params={ + 'lat': latitude or self.latitude, + 'lng': longitude or self.longitude, + }).json().get('results', {}) + + schema = SunEventsSchema() + return schema.dump({ + attr: self._convert_time(t) + for attr, t in response.items() + if attr in schema.declared_fields.keys() + }) diff --git a/platypush/schemas/__init__.py b/platypush/schemas/__init__.py index e69de29bb..c4c103f97 100644 --- a/platypush/schemas/__init__.py +++ b/platypush/schemas/__init__.py @@ -0,0 +1,10 @@ +from datetime import datetime +from typing import Optional + + +def normalize_datetime(dt: str) -> Optional[datetime]: + if not dt: + return + if dt.endswith('Z'): + dt = dt[:-1] + '+00:00' + return datetime.fromisoformat(dt) diff --git a/platypush/schemas/spotify.py b/platypush/schemas/spotify.py index 11c8c41f3..1a809ae75 100644 --- a/platypush/schemas/spotify.py +++ b/platypush/schemas/spotify.py @@ -1,11 +1,12 @@ from datetime import datetime -from typing import Union, Optional +from typing import Union from marshmallow import fields, pre_dump from marshmallow.schema import Schema from marshmallow.validate import OneOf, Range from platypush.plugins.media import PlayerState +from platypush.schemas import normalize_datetime device_types = [ 'Unknown', @@ -20,14 +21,6 @@ device_types = [ ] -def normalize_datetime(dt: str) -> Optional[datetime]: - if not dt: - return - if dt.endswith('Z'): - dt = dt[:-1] + '+00:00' - return datetime.fromisoformat(dt) - - class SpotifySchema(Schema): @staticmethod def _normalize_timestamp(t: Union[str, datetime]) -> datetime: diff --git a/platypush/schemas/sun.py b/platypush/schemas/sun.py new file mode 100644 index 000000000..199733e6e --- /dev/null +++ b/platypush/schemas/sun.py @@ -0,0 +1,14 @@ +from marshmallow import fields +from marshmallow.schema import Schema + + +class SunEventsSchema(Schema): + sunrise = fields.DateTime(metadata=dict(description='Next sunrise time')) + sunset = fields.DateTime(metadata=dict(description='Next sunset time')) + solar_noon = fields.DateTime(metadata=dict(description='Next solar noon time')) + civil_twilight_begin = fields.DateTime(metadata=dict(description='Next civil twilight start time')) + civil_twilight_end = fields.DateTime(metadata=dict(description='Next civil twilight end time')) + nautical_twilight_begin = fields.DateTime(metadata=dict(description='Next nautical twilight start time')) + nautical_twilight_end = fields.DateTime(metadata=dict(description='Next nautical twilight end time')) + astronomical_twilight_begin = fields.DateTime(metadata=dict(description='Next astronomical twilight start time')) + astronomical_twilight_end = fields.DateTime(metadata=dict(description='Next astronomical twilight end time'))