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: