Implemented alarm/timer plugin (closes #111)
This commit is contained in:
parent
8248b5353f
commit
a0ceb560b4
5 changed files with 429 additions and 3 deletions
286
platypush/backend/alarm.py
Normal file
286
platypush/backend/alarm.py
Normal 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:
|
46
platypush/message/event/alarm.py
Normal file
46
platypush/message/event/alarm.py
Normal 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:
|
88
platypush/plugins/alarm.py
Normal file
88
platypush/plugins/alarm.py
Normal 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:
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -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()
|
||||
|
||||
|
|
Loading…
Reference in a new issue