diff --git a/home/appliance/sound/player/state/fade_in/callable.py b/home/appliance/sound/player/state/fade_in/callable.py index 3863f20..d7a1439 100644 --- a/home/appliance/sound/player/state/fade_in/callable.py +++ b/home/appliance/sound/player/state/fade_in/callable.py @@ -12,6 +12,7 @@ class Elapsed(Callable): def run(self, event, state): if event == home.event.elapsed.Event.On: state = self.get_new_state(state, "off") + state = state.next(home.event.elapsed.Event.Off) return state diff --git a/home/builder/scheduler/trigger/state/entering/__init__.py b/home/builder/scheduler/trigger/state/entering/__init__.py index 0ef3899..b4f72c6 100644 --- a/home/builder/scheduler/trigger/state/entering/__init__.py +++ b/home/builder/scheduler/trigger/state/entering/__init__.py @@ -23,3 +23,4 @@ def _build_args(self, mapping): from home.builder.scheduler.trigger.state.entering import delay +from home.builder.scheduler.trigger.state.entering import disable_events diff --git a/home/builder/scheduler/trigger/state/entering/delay/__init__.py b/home/builder/scheduler/trigger/state/entering/delay/__init__.py index b385df1..fc37dc6 100644 --- a/home/builder/scheduler/trigger/state/entering/delay/__init__.py +++ b/home/builder/scheduler/trigger/state/entering/delay/__init__.py @@ -37,3 +37,4 @@ def _build_args(self, mapping): from home.builder.scheduler.trigger.state.entering.delay import duration +from home.builder.scheduler.trigger.state.entering.delay import enable_events diff --git a/home/builder/scheduler/trigger/state/entering/delay/enable_events.py b/home/builder/scheduler/trigger/state/entering/delay/enable_events.py new file mode 100644 index 0000000..6557e13 --- /dev/null +++ b/home/builder/scheduler/trigger/state/entering/delay/enable_events.py @@ -0,0 +1,23 @@ +# SPDX-License-Identifier: GPL-3.0-only +# +# automate home devices +# +# Copyright (C) 2021 Maja Massarini + +from home.builder.scheduler.trigger.state.entering.delay import Builder as Parent +from home.scheduler.trigger.state.entering.delay.enable_events import Trigger + + +class Builder(Parent): + TAG_NAME = "state.entering.delay.enable_events.Trigger" + + @property + def trigger(self): + return Trigger + + def _build_args(self, mapping): + name = mapping["name"] + events = mapping["enable events"] + state = mapping["when appliance state became"] + timeout = mapping["and timeout expires"] + return [name, events, state, timeout] diff --git a/home/builder/scheduler/trigger/state/entering/disable_events.py b/home/builder/scheduler/trigger/state/entering/disable_events.py new file mode 100644 index 0000000..edb366b --- /dev/null +++ b/home/builder/scheduler/trigger/state/entering/disable_events.py @@ -0,0 +1,22 @@ +# SPDX-License-Identifier: GPL-3.0-only +# +# automate home devices +# +# Copyright (C) 2021 Maja Massarini + +from home.builder.scheduler.trigger.state.entering import Builder as Parent +from home.scheduler.trigger.state.entering.disable_events import Trigger + + +class Builder(Parent): + TAG_NAME = "state.entering.disable_events.Trigger" + + @property + def trigger(self): + return Trigger + + def _build_args(self, mapping): + name = mapping["name"] + events = mapping["disable events"] + state = mapping["when appliance state became"] + return [name, events, state] diff --git a/home/process.py b/home/process.py index d69d58d..34189ac 100644 --- a/home/process.py +++ b/home/process.py @@ -98,7 +98,11 @@ async def _update_performers_by_protocol_trigger(self, scheduler, trigger): async def _on_appliance_updated_by_redis(self, scheduler, new_appliance): old_appliance = self._appliances[new_appliance.name] old_state, new_state = old_appliance.update(new_appliance) - self._logger.debug("Appliance {} updated by redis".format(new_appliance.name)) + self._logger.debug( + "Appliance {} updated by redis ({} -> {})".format( + new_appliance.name, old_state.compute(), new_state.compute() + ) + ) for performer in [ performer for performer in self._performers @@ -113,9 +117,10 @@ async def _on_appliance_updated_by_redis(self, scheduler, new_appliance): ) msgs = performer.execute(old_state, new_state) if msgs: - self._logger.info( - "Performer {} updated by redis will send {}".format( - performer.name, msgs + self._logger.debug( + "Performer {} sending {} ({} -> {})".format( + performer.name, msgs, + old_state.compute(), new_state.compute() ) ) for writer in self._protocols_writers: @@ -128,9 +133,10 @@ async def _on_performer_updated_by_redis(self, performer, old_state, new_state): msgs = performer.execute(old_state, new_state) self._logger.debug("Performer {} updated by redis".format(performer.name)) if msgs: - self._logger.info( - "Performer {} updated by redis will send {}".format( - performer.name, msgs + self._logger.debug( + "Performer {} sending {} ({} -> {})".format( + performer.name, msgs, + old_state.compute(), new_state.compute() ) ) for writer in self._protocols_writers: @@ -148,27 +154,51 @@ async def _run(self, scheduler): performer, trigger, events = await self._queue.get() if trigger.is_enabled and events: try: - msgs, old_state, new_state = performer.notify(events) - self._logger.debug( - "Performer {} notified by Scheduler Trigger {}".format( - performer.name, trigger.name + if isinstance( + trigger, + home.scheduler.trigger.state.entering.disable_events.Trigger, + ): + for event in events: + performer.appliance.disable(event) + self._logger.debug( + "Performer {} disabled events {} by Trigger {}".format( + performer.name, events, trigger.name + ) ) - ) - await self._redis_gateway.on_performer_updated_by_process( - performer, old_state, new_state - ) - if msgs: - self._logger.info( - "Performer {} called by Protocol Trigger {} will send {}".format( - performer.name, trigger.name, msgs + elif isinstance( + trigger, + home.scheduler.trigger.date.enable_events.Trigger, + ): + for event in events: + performer.appliance.enable(event) + self._logger.debug( + "Performer {} enabled events {} by Trigger {}".format( + performer.name, events, trigger.name ) ) - for writer in self._protocols_writers: - await writer(msgs, performer) + else: + msgs, old_state, new_state = performer.notify(events) + self._logger.debug( + "Performer {} notified by Scheduler Trigger {}".format( + performer.name, trigger.name + ) + ) + await self._redis_gateway.on_performer_updated_by_process( + performer, old_state, new_state + ) + if msgs: + self._logger.info( + "Performer {} called by Protocol Trigger {}" + " will send {}".format( + performer.name, trigger.name, msgs + ) + ) + for writer in self._protocols_writers: + await writer(msgs, performer) - await self._schedule_by_appliance_state( - scheduler, performer.appliance, old_state, new_state - ) + await self._schedule_by_appliance_state( + scheduler, performer.appliance, old_state, new_state + ) except Exception as e: self._logger.error(e) if trigger.is_enabled: @@ -191,9 +221,9 @@ def create_tasks(self, loop, scheduler): async def monitor(self): while True: - self._logger.warning("\n\nNew tasks:\n") + self._logger.debug("\n\nNew tasks:\n") for task in asyncio.all_tasks(): - self._logger.warning(task.get_name()) + self._logger.debug(task.get_name()) await asyncio.sleep(180) def run(self, scheduler): diff --git a/home/redis/gateway/__init__.py b/home/redis/gateway/__init__.py index 3723c02..5182d2f 100644 --- a/home/redis/gateway/__init__.py +++ b/home/redis/gateway/__init__.py @@ -102,11 +102,13 @@ async def on_performer_updated_by_process(self, performer, old_state, new_state) def create_tasks( self, loop, on_appliance_updated_by_redis, on_performer_updated_by_redis ): - for client in self._appliances.values(): + for appliance, client in self._appliances.items(): asyncio.get_event_loop().create_task( - client.run(on_appliance_updated_by_redis), name="Appliance updated by redis" + client.run(on_appliance_updated_by_redis), + name="Appliance {} updated by redis".format(appliance.name), ) - for client in self._performers.values(): + for performer, client in self._performers.items(): asyncio.get_event_loop().create_task( - client.run(on_performer_updated_by_redis), name="Performer updated by redis" + client.run(on_performer_updated_by_redis), + name="Performer {} updated by redis".format(performer.name), ) diff --git a/home/scheduler/trigger/date/__init__.py b/home/scheduler/trigger/date/__init__.py index 98abbe7..7b66757 100644 --- a/home/scheduler/trigger/date/__init__.py +++ b/home/scheduler/trigger/date/__init__.py @@ -20,3 +20,4 @@ def __init__(self, name: str, events: Iterable["home.Event"], *args, **kwargs): from home.scheduler.trigger.date import resettable +from home.scheduler.trigger.date import enable_events diff --git a/home/scheduler/trigger/date/enable_events.py b/home/scheduler/trigger/date/enable_events.py new file mode 100644 index 0000000..df6d084 --- /dev/null +++ b/home/scheduler/trigger/date/enable_events.py @@ -0,0 +1,14 @@ +# SPDX-License-Identifier: GPL-3.0-only +# +# automate home devices +# +# Copyright (C) 2021 Maja Massarini + +from home.scheduler.trigger.date.resettable import Trigger as Parent + + +class Trigger(Parent): + """ + A resettable date trigger that, when fired, re-enables events + in the Appliance state machine. + """ diff --git a/home/scheduler/trigger/state/entering/__init__.py b/home/scheduler/trigger/state/entering/__init__.py index 0755346..4d7324a 100644 --- a/home/scheduler/trigger/state/entering/__init__.py +++ b/home/scheduler/trigger/state/entering/__init__.py @@ -45,3 +45,4 @@ def __str__(self): from home.scheduler.trigger.state.entering import delay +from home.scheduler.trigger.state.entering import disable_events diff --git a/home/scheduler/trigger/state/entering/delay/__init__.py b/home/scheduler/trigger/state/entering/delay/__init__.py index e6f5269..097536f 100644 --- a/home/scheduler/trigger/state/entering/delay/__init__.py +++ b/home/scheduler/trigger/state/entering/delay/__init__.py @@ -49,3 +49,4 @@ def __str__(self): from home.scheduler.trigger.state.entering.delay import duration +from home.scheduler.trigger.state.entering.delay import enable_events diff --git a/home/scheduler/trigger/state/entering/delay/enable_events.py b/home/scheduler/trigger/state/entering/delay/enable_events.py new file mode 100644 index 0000000..f909209 --- /dev/null +++ b/home/scheduler/trigger/state/entering/delay/enable_events.py @@ -0,0 +1,67 @@ +# SPDX-License-Identifier: GPL-3.0-only +# +# automate home devices +# +# Copyright (C) 2021 Maja Massarini + +import copy +import datetime +from typing import Iterable, List, Tuple + +from home.scheduler.trigger.date import enable_events as date_enable_events +from home.scheduler.trigger.protocol.delay import Delay +from home.scheduler.trigger.state.entering.delay import Trigger as Parent + + +class _EnableEventsDelay(Delay): + def fork( + self, performer: "home.Performer" + ) -> List[Tuple["home.Performer", "home.scheduler.Trigger"]]: + result = list() + name = ( + "date.enable_events.Trigger for parent trigger" + " {} and performer {}".format(self._trigger_name, performer.name) + ) + run_date = datetime.datetime.now() + datetime.timedelta( + seconds=self._timeout + ) + if name in self._last_resettable_trigger: + self._last_resettable_trigger[name].disable() + self._last_resettable_trigger[name] = date_enable_events.Trigger( + name, + self._scheduler_trigger_events, + run_date=run_date, + timezone=self._timezone, + ) + result.append((performer, self._last_resettable_trigger[name])) + return result + + +class Trigger(Parent): + """ + A **Scheduler Trigger** that, when entering the specified state, + starts a timer and after the given timeout re-enables the specified events + in the Appliance state machine. + + >>> import home + >>> off = home.appliance.sound.player.state.off.State() + >>> fade_in = home.appliance.sound.player.state.fade_in.State() + >>> trigger = Trigger("re-enable forced off", [], 'Fade In', 1) + >>> trigger.is_triggered(off, fade_in) + True + """ + + def __init__( + self, + name: str, + events: Iterable["home.Event"], + state: str, + timeout_seconds: float, + ): + super().__init__(name, events, state, timeout_seconds) + self._delay = _EnableEventsDelay( + "delay trigger for {}".format(name), + copy.deepcopy(events), + timeout_seconds, + self._timezone, + ) diff --git a/home/scheduler/trigger/state/entering/disable_events.py b/home/scheduler/trigger/state/entering/disable_events.py new file mode 100644 index 0000000..d5867fa --- /dev/null +++ b/home/scheduler/trigger/state/entering/disable_events.py @@ -0,0 +1,14 @@ +# SPDX-License-Identifier: GPL-3.0-only +# +# automate home devices +# +# Copyright (C) 2021 Maja Massarini + +from home.scheduler.trigger.state.entering import Trigger as Parent + + +class Trigger(Parent): + """ + A **Scheduler Trigger** that, when entering the specified state, + disables the specified events in the Appliance state machine. + """ diff --git a/home/tests/test_scheduler_trigger_disable_enable_events.py b/home/tests/test_scheduler_trigger_disable_enable_events.py new file mode 100644 index 0000000..a7f6379 --- /dev/null +++ b/home/tests/test_scheduler_trigger_disable_enable_events.py @@ -0,0 +1,131 @@ +# SPDX-License-Identifier: GPL-3.0-only +# +# automate home devices +# +# Copyright (C) 2021 Maja Massarini + +import asyncio +import unittest + +import home +from home.tests.testcase import TestCase, Trigger + + +class TriggerFadeIn(Trigger): + """Protocol trigger that drives the sound player into Fade In state.""" + + DEFAULT_EVENTS = [home.event.sleepiness.Event.Awake] + NAME = "Trigger fade in" + + def is_triggered(self, another_description): + return True + + +class Stub(home.MyHome): + def _build_appliances(self): + player = home.appliance.sound.player.Appliance("player", []) + collection = home.appliance.Collection() + collection["players"] = set([player]) + return collection + + def _build_performers(self): + performer = home.Performer( + "player triggers", + self.appliances.find("player"), + [], + [TriggerFadeIn()], + ) + return [performer] + + def _build_group_of_performers(self): + return {"player triggers": [self._performers[0]]} + + def _build_scheduler_triggers(self): + return [ + home.scheduler.trigger.state.entering.disable_events.Trigger( + name="disable forced off on fade in", + events=[home.appliance.sound.player.event.forced.Event.Off], + state="Fade In", + ), + home.scheduler.trigger.state.entering.delay.enable_events.Trigger( + name="enable forced off after fade in", + events=[home.appliance.sound.player.event.forced.Event.Off], + state="Fade In", + timeout_seconds=1, + ), + ] + + def _build_schedule_infos(self): + return [ + ( + self.find_group_of_performers("player triggers"), + self.find_scheduler_triggers("disable forced off on fade in"), + ), + ( + self.find_group_of_performers("player triggers"), + self.find_scheduler_triggers("enable forced off after fade in"), + ), + ] + + +class TestDisableEnableEvents(TestCase): + def test_forced_off_disabled_on_fade_in_then_re_enabled(tc): + """ + When the sound player enters Fade In: + 1. forced.Event.Off is immediately disabled so any echo from + Sonos is ignored. + 2. After the configured timeout forced.Event.Off is re-enabled + so that legitimate stop/pause commands are processed again. + """ + tc.myhome = Stub() + tc.make_process(tc.myhome) + events = [] + + class Test(unittest.IsolatedAsyncioTestCase): + + EVENT_DISABLED = "forced_off_disabled" + EVENT_ENABLED = "forced_off_enabled" + MAX_LOOP = 20 + + async def asyncSetUp(self): + self._loop = asyncio.get_event_loop() + tc.create_tasks(self._loop, tc.myhome) + self._loop.create_task(self.check_state()) + self._loop.create_task(self.emulate_bus_events()) + + async def asyncTearDown(self): + tc.scheduler.shutdown() + + async def check_state(self): + while True: + await asyncio.sleep(0.1) + player = tc.myhome.appliances.find("player") + forced_off = ( + home.appliance.sound.player.event.forced.Event.Off + ) + if self.EVENT_DISABLED not in events: + if not player.is_enabled(forced_off): + events.append(self.EVENT_DISABLED) + elif self.EVENT_ENABLED not in events: + if player.is_enabled(forced_off): + events.append(self.EVENT_ENABLED) + + async def test_state(self): + i = 0 + while ( + self.EVENT_ENABLED not in events and i < self.MAX_LOOP + ): + await asyncio.sleep(0.3) + i += 1 + + async def emulate_bus_events(self): + await asyncio.sleep(0.1) + trigger = TriggerFadeIn.make_from(None) + await tc.process._update_performers_by_protocol_trigger( + tc.scheduler, trigger + ) + + test = Test("test_state") + test.run() + tc.assertIn(Test.EVENT_DISABLED, events) + tc.assertIn(Test.EVENT_ENABLED, events)