platypush/platypush/backend/alarm.py

305 lines
10 KiB
Python

import datetime
import enum
import os
import time
import threading
from typing import Optional, Union, Dict, Any, List
import croniter
from dateutil.tz import gettz
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,
audio_volume: Optional[Union[int, float]] = 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.audio_volume = audio_volume
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 = datetime.datetime.now().replace(tzinfo=gettz()) # lgtm [py/call-to-non-callable]
try:
cron = croniter.croniter(self.when, now)
return cron.get_next()
except (AttributeError, croniter.CroniterBadCronError):
try:
timestamp = datetime.datetime.fromisoformat(self.when).replace(
tzinfo=gettz()) # lgtm [py/call-to-non-callable]
except (TypeError, ValueError):
timestamp = (datetime.datetime.now().replace(tzinfo=gettz()) + # lgtm [py/call-to-non-callable]
datetime.timedelta(seconds=int(self.when)))
return timestamp.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)
if self.audio_volume is not None:
self._get_audio_plugin().set_volume(self.audio_volume)
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
audio_plugin: media.mplayer
audio_volume: 10 # 10%
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,
audio_volume: Optional[Union[int, float]] = None, enabled: bool = True) -> Alarm:
alarm = Alarm(when=when, actions=actions, name=name, enabled=enabled, audio_file=audio_file,
audio_plugin=self.audio_plugin, audio_volume=audio_volume)
if alarm.name in self.alarms:
self.logger.info('Overwriting existing alarm {}'.format(alarm.name))
self.alarms[alarm.name].stop()
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: