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 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)
|
||||||
|
|
||||||
|
|
|
@ -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()
|
||||||
|
|
||||||
|
|
Loading…
Reference in a new issue