Implemented alarm/timer plugin (closes #111)

This commit is contained in:
Fabio Manganiello 2020-02-17 19:37:22 +01:00
parent 8248b5353f
commit a0ceb560b4
5 changed files with 429 additions and 3 deletions

286
platypush/backend/alarm.py Normal file
View file

@ -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:

View file

@ -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:

View file

@ -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:

View file

@ -8,8 +8,6 @@ import subprocess
import tempfile import tempfile
import threading import threading
from platypush.plugins.media.search import YoutubeMediaSearcher
from platypush.config import Config from platypush.config import Config
from platypush.context import get_plugin, get_backend from platypush.context import get_plugin, get_backend
from platypush.plugins import Plugin, action from platypush.plugins import Plugin, action
@ -452,6 +450,7 @@ class MediaPlugin(Plugin):
@staticmethod @staticmethod
def _youtube_search_html_parse(query): def _youtube_search_html_parse(query):
from .search import YoutubeMediaSearcher
# noinspection PyProtectedMember # noinspection PyProtectedMember
return YoutubeMediaSearcher()._youtube_search_html_parse(query) return YoutubeMediaSearcher()._youtube_search_html_parse(query)

View file

@ -4,7 +4,7 @@ import subprocess
import threading import threading
import time import time
from platypush.context import get_bus, get_plugin from platypush.context import get_bus
from platypush.message.response import Response from platypush.message.response import Response
from platypush.plugins.media import PlayerState, MediaPlugin from platypush.plugins.media import PlayerState, MediaPlugin
from platypush.message.event.media import MediaPlayEvent, MediaPlayRequestEvent, \ from platypush.message.event.media import MediaPlayEvent, MediaPlayRequestEvent, \
@ -148,6 +148,10 @@ class MediaMplayerPlugin(MediaPlugin):
cmd_name, ' ' if args else '', cmd_name, ' ' if args else '',
' '.join(repr(a) for a in args)).encode() ' '.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.write(cmd)
self._player.stdin.flush() self._player.stdin.flush()
@ -175,6 +179,9 @@ class MediaMplayerPlugin(MediaPlugin):
while time.time() - last_read_time < self._mplayer_timeout: while time.time() - last_read_time < self._mplayer_timeout:
result = poll.poll(0) result = poll.poll(0)
if result: if result:
if not self._player:
break
line = self._player.stdout.readline().decode() line = self._player.stdout.readline().decode()
last_read_time = time.time() last_read_time = time.time()