From a0ceb560b490a80975374e227d978f6a247289fe Mon Sep 17 00:00:00 2001 From: Fabio Manganiello Date: Mon, 17 Feb 2020 19:37:22 +0100 Subject: [PATCH] Implemented alarm/timer plugin (closes #111) --- platypush/backend/alarm.py | 286 ++++++++++++++++++++++++++++ platypush/message/event/alarm.py | 46 +++++ platypush/plugins/alarm.py | 88 +++++++++ platypush/plugins/media/__init__.py | 3 +- platypush/plugins/media/mplayer.py | 9 +- 5 files changed, 429 insertions(+), 3 deletions(-) create mode 100644 platypush/backend/alarm.py create mode 100644 platypush/message/event/alarm.py create mode 100644 platypush/plugins/alarm.py diff --git a/platypush/backend/alarm.py b/platypush/backend/alarm.py new file mode 100644 index 00000000..7b086351 --- /dev/null +++ b/platypush/backend/alarm.py @@ -0,0 +1,286 @@ +import datetime +import os +import time +import threading + +from typing import Optional, Union, Dict, Any, List + +import croniter +import enum + +from platypush.backend import Backend +from platypush.context import get_bus, get_plugin +from platypush.message.event.alarm import AlarmStartedEvent, AlarmDismissedEvent, AlarmSnoozedEvent +from platypush.plugins.media import MediaPlugin, PlayerState +from platypush.procedure import Procedure + + +class AlarmState(enum.IntEnum): + WAITING = 1 + RUNNING = 2 + DISMISSED = 3 + SNOOZED = 4 + SHUTDOWN = 5 + + +class Alarm: + _alarms_count = 0 + _id_lock = threading.RLock() + + def __init__(self, when: str, actions: Optional[list] = None, name: Optional[str] = None, + audio_file: Optional[str] = None, audio_plugin: Optional[str] = None, + snooze_interval: float = 300.0, enabled: bool = True): + with self._id_lock: + self._alarms_count += 1 + self.id = self._alarms_count + + self.when = when + self.name = name or 'Alarm_{}'.format(self.id) + self.audio_file = None + + if audio_file: + self.audio_file = os.path.abspath(os.path.expanduser(audio_file)) + assert os.path.isfile(self.audio_file), 'No such audio file: {}'.format(self.audio_file) + + self.audio_plugin = audio_plugin + self.snooze_interval = snooze_interval + self.state: Optional[AlarmState] = None + self.timer: Optional[threading.Timer] = None + self.actions = Procedure.build(name=name, _async=False, requests=actions or [], id=self.id) + + self._enabled = enabled + self._runtime_snooze_interval = snooze_interval + + def get_next(self) -> float: + now = time.time() + + try: + cron = croniter.croniter(self.when, now) + return cron.get_next() + except croniter.CroniterBadCronError: + timestamp = datetime.datetime.fromisoformat(self.when).timestamp() + return timestamp if timestamp >= now else None + + def is_enabled(self): + return self._enabled + + def disable(self): + self._enabled = False + + def enable(self): + self._enabled = True + + def dismiss(self): + self.state = AlarmState.DISMISSED + self.stop_audio() + get_bus().post(AlarmDismissedEvent(name=self.name)) + + def snooze(self, interval: Optional[float] = None): + self._runtime_snooze_interval = interval or self.snooze_interval + self.state = AlarmState.SNOOZED + self.stop_audio() + get_bus().post(AlarmSnoozedEvent(name=self.name, interval=self._runtime_snooze_interval)) + + def start(self): + if self.timer: + self.timer.cancel() + + if self.get_next() is None: + return + + interval = self.get_next() - time.time() + self.timer = threading.Timer(interval, self.callback()) + self.timer.start() + self.state = AlarmState.WAITING + + def stop(self): + self.state = AlarmState.SHUTDOWN + if self.timer: + self.timer.cancel() + self.timer = None + + def _get_audio_plugin(self) -> MediaPlugin: + return get_plugin(self.audio_plugin) + + def play_audio(self): + def thread(): + self._get_audio_plugin().play(self.audio_file) + + self.state = AlarmState.RUNNING + audio_thread = threading.Thread(target=thread) + audio_thread.start() + + def stop_audio(self): + self._get_audio_plugin().stop() + + def callback(self): + def _callback(): + while True: + if self.state == AlarmState.SHUTDOWN: + break + + if self.is_enabled(): + get_bus().post(AlarmStartedEvent(name=self.name)) + if self.audio_plugin and self.audio_file: + self.play_audio() + + self.actions.execute() + + time.sleep(10) + sleep_time = None + if self.state == AlarmState.RUNNING: + while True: + state = self._get_audio_plugin().status().output.get('state') + if state == PlayerState.STOP.value: + if self.state == AlarmState.SNOOZED: + sleep_time = self._runtime_snooze_interval + else: + self.state = AlarmState.WAITING + + break + else: + time.sleep(10) + + if self.state == AlarmState.SNOOZED: + sleep_time = self._runtime_snooze_interval + elif self.get_next() is None: + self.state = AlarmState.SHUTDOWN + break + + if not sleep_time: + sleep_time = self.get_next() - time.time() if self.get_next() else 10 + + time.sleep(sleep_time) + + return _callback + + def to_dict(self): + return { + 'name': self.name, + 'id': self.id, + 'when': self.when, + 'next_run': self.get_next(), + 'enabled': self.is_enabled(), + 'state': self.state.name, + } + + +class AlarmBackend(Backend): + """ + Backend to handle user-configured alarms. + + Triggers: + + * :class:`platypush.message.event.alarm.AlarmStartedEvent` when an alarm starts. + * :class:`platypush.message.event.alarm.AlarmSnoozedEvent` when an alarm is snoozed. + * :class:`platypush.message.event.alarm.AlarmTimeoutEvent` when an alarm times out. + * :class:`platypush.message.event.alarm.AlarmDismissedEvent` when an alarm is dismissed. + + """ + + def __init__(self, alarms: Optional[Union[list, Dict[str, Any]]] = None, audio_plugin: str = 'media.mplayer', + *args, **kwargs): + """ + :param alarms: List or name->value dict with the configured alarms. Example: + + .. code-block:: yaml + + morning_alarm: + when: '0 7 * * 1-5' # Cron expression format: run every weekday at 7 AM + audio_file: ~/path/your_ringtone.mp3 + snooze_interval: 300 # 5 minutes snooze + actions: + - action: tts.say + args: + text: Good morning + + - action: light.hue.bri + args: + value: 1 + + - action: light.hue.bri + args: + value: 140 + transitiontime: 150 + + one_shot_alarm: + when: '2020-02-18T07:00:00.000000' # One-shot execution, with timestamp in ISO format + audio_file: ~/path/your_ringtone.mp3 + actions: + - action: light.hue.on + + :param audio_plugin: Media plugin (instance of :class:`platypush.plugins.media.MediaPlugin`) that will be + used to play the alarm audio (default: ``media.mplayer``). + """ + super().__init__(*args, **kwargs) + alarms = alarms or [] + if isinstance(alarms, dict): + alarms = [{'name': name, **alarm} for name, alarm in alarms.items()] + + self.audio_plugin = audio_plugin + alarms = [Alarm(**{'audio_plugin': self.audio_plugin, **alarm}) for alarm in alarms] + self.alarms: Dict[str, Alarm] = {alarm.name: alarm for alarm in alarms} + + def add_alarm(self, when: str, actions: list, name: Optional[str] = None, audio_file: Optional[str] = None, + enabled: bool = True) -> Alarm: + alarm = Alarm(when=when, actions=actions, name=name, enabled=enabled, audio_file=audio_file) + assert alarm.name not in self.alarms, 'Alarm {} already exists'.format(alarm.name) + self.alarms[alarm.name] = alarm + self.alarms[alarm.name].start() + return self.alarms[alarm.name] + + def _get_alarm(self, name) -> Alarm: + assert name in self.alarms, 'Alarm {} does not exist'.format(name) + return self.alarms[name] + + def enable_alarm(self, name: str): + self._get_alarm(name).enable() + + def disable_alarm(self, name: str): + self._get_alarm(name).disable() + + def dismiss_alarm(self): + alarm = self.get_running_alarm() + if not alarm: + self.logger.info('No alarm is running') + return + + alarm.dismiss() + + def snooze_alarm(self, interval: Optional[str] = None): + alarm = self.get_running_alarm() + if not alarm: + self.logger.info('No alarm is running') + return + + alarm.snooze(interval=interval) + + def get_alarms(self) -> List[Alarm]: + return sorted([alarm for alarm in self.alarms.values()], key=lambda alarm: alarm.get_next()) + + def get_running_alarm(self) -> Optional[Alarm]: + running_alarms = [alarm for alarm in self.alarms.values() if alarm.state == AlarmState.RUNNING] + return running_alarms[0] if running_alarms else None + + def __enter__(self): + for alarm in self.alarms.values(): + alarm.stop() + alarm.start() + + self.logger.info('Initialized alarm backend with {} alarms'.format(len(self.alarms))) + + def __exit__(self, exc_type, exc_val, exc_tb): + for alarm in self.alarms.values(): + alarm.stop() + + self.logger.info('Alarm backend terminated') + + def loop(self): + for name, alarm in self.alarms.copy().items(): + if not alarm.timer or (not alarm.timer.is_alive() and alarm.state == AlarmState.SHUTDOWN): + del self.alarms[name] + + time.sleep(10) + + +# vim:sw=4:ts=4:et: diff --git a/platypush/message/event/alarm.py b/platypush/message/event/alarm.py new file mode 100644 index 00000000..9a70230c --- /dev/null +++ b/platypush/message/event/alarm.py @@ -0,0 +1,46 @@ +from typing import Optional + +from platypush.message.event import Event + + +class AlarmEvent(Event): + def __init__(self, name: Optional[str] = None, *args, **kwargs): + super().__init__(*args, name=name, **kwargs) + + +class AlarmStartedEvent(AlarmEvent): + """ + Triggered when an alarm starts. + """ + pass + + +class AlarmEndedEvent(AlarmEvent): + """ + Triggered when an alarm stops. + """ + pass + + +class AlarmDismissedEvent(AlarmEndedEvent): + """ + Triggered when an alarm is dismissed. + """ + pass + + +class AlarmSnoozedEvent(AlarmEvent): + """ + Triggered when an alarm is snoozed. + """ + pass + + +class AlarmTimeoutEvent(AlarmEndedEvent): + """ + Triggered when an alarm times out. + """ + pass + + +# vim:sw=4:ts=4:et: diff --git a/platypush/plugins/alarm.py b/platypush/plugins/alarm.py new file mode 100644 index 00000000..df16000f --- /dev/null +++ b/platypush/plugins/alarm.py @@ -0,0 +1,88 @@ +from typing import Optional, Dict, Any, List + +from platypush.backend.alarm import AlarmBackend +from platypush.context import get_backend +from platypush.plugins import Plugin, action + + +class AlarmPlugin(Plugin): + """ + Alarm/timer plugin. + + Requires: + + - The :class:`platypush.backend.alarm.AlarmBackend` backend configured and enabled. + + """ + + @staticmethod + def _get_backend() -> AlarmBackend: + return get_backend('alarm') + + @action + def add(self, when: str, actions: list, name: Optional[str] = None, audio_file: Optional[str] = None, + enabled: bool = True) -> str: + """ + Add a new alarm. NOTE: alarms that aren't configured in the :class:`platypush.backend.alarm.AlarmBackend` + will only run in the current session. If you want an alarm to be permanently stored, you should configure + it in the alarm backend configuration. You may want to add an alarm dynamically if it's a one-time alarm instead + + :param when: When the alarm should be executed. It can be either a cron expression (for recurrent alarms), or + a datetime string in ISO format (for one-shot alarms/timers). + :param actions: List of actions to be executed. + :param name: Alarm name. + :param audio_file: Path of the audio file to be played. + :param enabled: Whether the new alarm should be enabled (default: True). + :return: The alarm name. + """ + alarm = self._get_backend().add_alarm(when=when, audio_file=audio_file, actions=actions, + name=name, enabled=enabled) + return alarm.name + + @action + def enable(self, name: str): + """ + Enable an alarm. + + :param name: Alarm name. + """ + self._get_backend().enable_alarm(name) + + @action + def disable(self, name: str): + """ + Disable an alarm. This will prevent the alarm from executing until re-enabled or until the application + is restarted. + + :param name: Alarm name. + """ + self._get_backend().disable_alarm(name) + + @action + def dismiss(self): + """ + Dismiss the alarm that is currently running. + """ + self._get_backend().dismiss_alarm() + + @action + def snooze(self, interval: Optional[float] = 300.0): + """ + Snooze the alarm that is currently running for the specified number of seconds. + The alarm will stop and resume again later. + + :param interval: Snooze seconds before playing the alarm again (default: 300). + """ + self._get_backend().snooze_alarm(interval=interval) + + @action + def get_alarms(self) -> List[Dict[str, Any]]: + """ + Get the list of configured alarms. + + :return: List of the alarms, sorted by next scheduled run. + """ + return [alarm.to_dict() for alarm in self._get_backend().get_alarms()] + + +# vim:sw=4:ts=4:et: diff --git a/platypush/plugins/media/__init__.py b/platypush/plugins/media/__init__.py index eaa5f1fa..402e1e34 100644 --- a/platypush/plugins/media/__init__.py +++ b/platypush/plugins/media/__init__.py @@ -8,8 +8,6 @@ import subprocess import tempfile import threading -from platypush.plugins.media.search import YoutubeMediaSearcher - from platypush.config import Config from platypush.context import get_plugin, get_backend from platypush.plugins import Plugin, action @@ -452,6 +450,7 @@ class MediaPlugin(Plugin): @staticmethod def _youtube_search_html_parse(query): + from .search import YoutubeMediaSearcher # noinspection PyProtectedMember return YoutubeMediaSearcher()._youtube_search_html_parse(query) diff --git a/platypush/plugins/media/mplayer.py b/platypush/plugins/media/mplayer.py index 021af72d..81a38005 100644 --- a/platypush/plugins/media/mplayer.py +++ b/platypush/plugins/media/mplayer.py @@ -4,7 +4,7 @@ import subprocess import threading import time -from platypush.context import get_bus, get_plugin +from platypush.context import get_bus from platypush.message.response import Response from platypush.plugins.media import PlayerState, MediaPlugin from platypush.message.event.media import MediaPlayEvent, MediaPlayRequestEvent, \ @@ -148,6 +148,10 @@ class MediaMplayerPlugin(MediaPlugin): cmd_name, ' ' if args else '', ' '.join(repr(a) for a in args)).encode() + if not self._player: + self.logger.warning('Cannot send command {}: player unavailable'.format(cmd)) + return + self._player.stdin.write(cmd) self._player.stdin.flush() @@ -175,6 +179,9 @@ class MediaMplayerPlugin(MediaPlugin): while time.time() - last_read_time < self._mplayer_timeout: result = poll.poll(0) if result: + if not self._player: + break + line = self._player.stdout.readline().decode() last_read_time = time.time()