diff --git a/button.py b/button.py index 4f7005e..5e84c98 100644 --- a/button.py +++ b/button.py @@ -1,4 +1,4 @@ - +import asyncio import json from appdaemon.plugins.mqtt.mqttapi import Mqtt @@ -7,8 +7,10 @@ from room_control import RoomController class ButtonController(Mqtt): def initialize(self): + task = self.get_app(self.args['app']) + self.app: RoomController = asyncio.get_event_loop().run_until_complete(task) self.setup_buttons(self.args['button']) - self.app: RoomController = self.get_app(self.args['app']) + # self.log(f'Done') def setup_buttons(self, buttons): if isinstance(buttons, list): @@ -21,11 +23,11 @@ class ButtonController(Mqtt): topic = f'zigbee2mqtt/{name}' self.mqtt_subscribe(topic, namespace='mqtt') self.listen_event(self.handle_button, "MQTT_MESSAGE", topic=topic, namespace='mqtt', button=name) - self.log(f'{name} controls app {self.args["app"]}') + self.log(f'"{topic}" controls app {self.app.name}') - def handle_button(self, event_name, data, kwargs): + async def handle_button(self, event_name, data, kwargs): topic = data['topic'] - # self.log(f'Button event for: {topic}') + self.log(f'Button event for: {topic}') try: payload = json.loads(data['payload']) action = payload['action'] @@ -36,16 +38,17 @@ class ButtonController(Mqtt): return else: self.log(f'{button}: {action}') - self.handle_action(action) + await self.handle_action(action) - def handle_action(self, action: str): + async def handle_action(self, action: str): if action == '': return elif action == 'single': cause = 'button single click' - if self.get_state(entity_id=self.args['ref_entity']) == 'on': + state = await self.get_state(entity_id=self.args['ref_entity']) + if state == 'on': self.app.deactivate(cause=cause) else: - self.app.activate(cause=cause) + await self.app.activate(cause=cause) else: pass \ No newline at end of file diff --git a/door.py b/door.py index 9d51b42..8b7a76e 100644 --- a/door.py +++ b/door.py @@ -2,10 +2,10 @@ from appdaemon.plugins.hass.hassapi import Hass from room_control import RoomController -class DoorControl(Hass): - def initialize(self): - self.listen_state(self.door_open, entity_id=self.args['door'], new='on') +class Door(Hass): + async def initialize(self): + await self.listen_state(self.door_open, entity_id=self.args['door'], new='on') - def door_open(self, entity, attribute, old, new, kwargs): - app: RoomController = self.get_app(self.args['app']) - app.activate_all_off() \ No newline at end of file + async def door_open(self, entity, attribute, old, new, kwargs): + app: RoomController = await self.get_app(self.args['app']) + await app.activate_all_off() diff --git a/motion.py b/motion.py index e58b7bb..33bf7eb 100644 --- a/motion.py +++ b/motion.py @@ -1,3 +1,4 @@ +import asyncio from datetime import timedelta import re @@ -6,6 +7,23 @@ from appdaemon.plugins.hass.hassapi import Hass from room_control import RoomController +class CustomEventLoopPolicy(asyncio.DefaultEventLoopPolicy): + def get_event_loop(self): + try: + # Try to get the current event loop + loop = super().get_event_loop() + except RuntimeError as ex: + if "There is no current event loop" in str(ex): + # If there's no current loop, create a new one and set it + loop = self.new_event_loop() + self.set_event_loop(loop) + else: + raise + return loop + +# Set the custom event loop policy +asyncio.set_event_loop_policy(CustomEventLoopPolicy()) + class Motion(Hass): @property def sensor(self) -> Entity: @@ -23,34 +41,35 @@ class Motion(Hass): def ref_entity_state(self) -> bool: return self.ref_entity.get_state() == 'on' - @property - def off_duration(self) -> timedelta: - return self.app.off_duration - def initialize(self): - self.app: RoomController = self.get_app(self.args['app']) + try: + loop = asyncio.get_event_loop() + except RuntimeError: + loop = asyncio.new_event_loop() + + self.app: RoomController = loop.run_until_complete(self.get_app(self.args['app'])) self.log(f'Connected to app {self.app.name}') self.listen_state(self.callback_light_on, self.ref_entity.entity_id, new='on') self.listen_state(self.callback_light_off, self.ref_entity.entity_id, new='off') - self.sync_state() + loop.run_until_complete(self.sync_state()) - def sync_state(self): + async def sync_state(self): """Synchronizes the callbacks with the state of the light. Essentially mimics the `state_change` callback based on the current state of the light. """ if self.ref_entity_state: - self.callback_light_on() + await self.callback_light_on() else: - self.callback_light_off() + await self.callback_light_off() - def listen_motion_on(self): + async def listen_motion_on(self): """Sets up the motion on callback to activate the room """ self.log(f'Waiting for motion on {self.sensor.friendly_name}') - self.motion_on_handle = self.listen_state( + self.motion_on_handle = await self.listen_state( callback=self.app.activate_all_off, entity_id=self.sensor.entity_id, new='on', @@ -58,11 +77,11 @@ class Motion(Hass): cause='motion on' ) - def listen_motion_off(self, duration: timedelta): + async def listen_motion_off(self, duration: timedelta): """Sets up the motion off callback to deactivate the room """ self.log(f'Waiting for motion to stop on {self.sensor.friendly_name}') - self.motion_off_handle = self.listen_state( + self.motion_off_handle = await self.listen_state( callback=self.app.deactivate, entity_id=self.sensor.entity_id, new='off', @@ -71,46 +90,46 @@ class Motion(Hass): cause='motion off' ) - def callback_light_on(self, entity=None, attribute=None, old=None, new=None, kwargs=None): + async def callback_light_on(self, entity=None, attribute=None, old=None, new=None, kwargs=None): """Called when the light turns on """ self.log('Light on callback') - self.cancel_motion_callback(new='on') - self.listen_motion_off(self.off_duration) + await self.cancel_motion_callback(new='on') + await self.listen_motion_off(await self.app.off_duration()) - def callback_light_off(self, entity=None, attribute=None, old=None, new=None, kwargs=None): + async def callback_light_off(self, entity=None, attribute=None, old=None, new=None, kwargs=None): """Called when the light turns off """ self.log('Light off callback') - self.cancel_motion_callback(new='off') - self.listen_motion_on() + await self.cancel_motion_callback(new='off') + await self.listen_motion_on() - def get_app_callbacks(self, name: str = None): + async def get_app_callbacks(self, name: str = None): """Gets all the callbacks associated with the app """ name = name or self.name callbacks = { handle: info - for app_name, callbacks in self.get_callback_entries().items() + for app_name, callbacks in (await self.get_callback_entries()).items() for handle, info in callbacks.items() if app_name == name } return callbacks - def get_sensor_callbacks(self): + async def get_sensor_callbacks(self): return { handle: info - for handle, info in self.get_app_callbacks().items() + for handle, info in (await self.get_app_callbacks()).items() if info['entity'] == self.sensor.entity_id } - def cancel_motion_callback(self, new: str): - callbacks = self.get_sensor_callbacks() + async def cancel_motion_callback(self, new: str): + callbacks = await self.get_sensor_callbacks() # self.log(f'Found {len(callbacks)}') for handle, info in callbacks.items(): entity = info["entity"] new_match = re.match('new=(?P.*?)\s', info['kwargs']) # self.log(f'{handle}: {info["entity"]}: {info["kwargs"]}') if new_match is not None and new_match.group("new") == new: - self.cancel_listen_state(handle) - self.log(f'cancelled: {self.friendly_name(entity)}: {new}') + await self.cancel_listen_state(handle) + self.log(f'cancelled: {await self.friendly_name(entity)}: {new}') diff --git a/room_control.py b/room_control.py index 3db6ff7..21e48b1 100755 --- a/room_control.py +++ b/room_control.py @@ -1,8 +1,11 @@ +import asyncio from copy import deepcopy from datetime import time, timedelta from typing import List +import appdaemon.utils as utils import astral +from appdaemon.entity import Entity from appdaemon.plugins.hass.hassapi import Hass from appdaemon.plugins.mqtt.mqttapi import Mqtt @@ -17,40 +20,63 @@ class RoomController(Hass, Mqtt): - When the light comes on, check if it's attributes match what they should, given the time. """ - def initialize(self): - self.app_entities = self.gather_app_entities() - self.refresh_state_times() - self.run_daily(callback=self.refresh_state_times, start='00:00:00') + async def initialize(self): + self.app_entities = await self.gather_app_entities() + self.log(f'entities: {self.app_entities}') + await self.refresh_state_times() + await self.run_daily(callback=self.refresh_state_times, start='00:00:00') - if (ha_button := self.args.get('ha_button')): - self.log(f'Setting up input button: {self.friendly_name(ha_button)}') - self.listen_state(callback=self.activate_any_on, entity_id=ha_button) + # if (ha_button := self.args.get('ha_button')): + # self.log(f'Setting up input button: {self.friendly_name(ha_button)}') + # self.listen_state(callback=self.activate_any_on, entity_id=ha_button) - def refresh_state_times(self, *args, **kwargs): + async def gather_app_entities(self) -> List[str]: + """Returns a list of all the entities involved in any of the states + """ + async def async_generator(): + for settings in deepcopy(self.args['states']): + if (scene := settings.get('scene')): + if isinstance(scene, str): + assert scene.startswith('scene.'), f"Scene definition must start with 'scene.' for app {self.name}" + entity: Entity = self.get_entity(scene) + entity_state = await entity.get_state('all') + attributes = entity_state['attributes'] + for entity in attributes['entity_id']: + yield entity + else: + for key in scene.keys(): + yield key + else: + yield self.args['entity'] + + entities = [e async for e in async_generator()] + return set(entities) + + async def refresh_state_times(self, *args, **kwargs): """Resets the `self.states` attribute to a newly parsed version of the states. Parsed states have an absolute time for a certain day. """ # re-parse the state strings into times for the current day - self.states = self.parse_states() + self.states = await self.parse_states() # schedule the transitions for state in self.states: dt = str(state['time'])[:8] self.log(f'Scheduling transition at: {dt}') try: - self.run_at(callback=self.activate_any_on, start=dt) + await self.run_at(callback=self.activate_any_on, start=dt) except ValueError: # happens when the callback time is in the past pass except Exception as e: self.log(f'Failed with {type(e)}: {e}') - def parse_states(self): - def gen(): + async def parse_states(self): + async def gen(): for state in deepcopy(self.args['states']): if (time := state.get('time')): - state['time'] = self.parse_time(time) + state['time'] = await self.parse_time(time) elif isinstance((elevation := state.get('elevation')), (int, float)): assert 'direction' in state, f'State needs a direction if it has an elevation' @@ -71,17 +97,20 @@ class RoomController(Hass, Mqtt): yield state - states = sorted(gen(), key=lambda s: s['time']) + states = [s async for s in gen()] + states = sorted(states, key=lambda s: s['time']) return states - def current_state(self, time: time = None): - if self.sleep_bool: + async def current_state(self, time: time = None): + if (await self.sleep_bool()): if (state := self.args.get('sleep_state')): return state else: return {} else: - time = time or self.get_now().time() + now = await self.get_now() + self.log(f'Getting state for datetime: {now}') + time = time or (await self.get_now()).time() for state in self.states[::-1]: if state['time'] <= time: self.log(f'Selected state from {state["time"]}') @@ -89,26 +118,10 @@ class RoomController(Hass, Mqtt): else: return self.states[-1] - def current_scene(self, time: time = None): - if (state := self.current_state(time=time)) is not None: + async def current_scene(self, time: time = None): + if (state := (await self.current_state(time=time))) is not None: return state['scene'] - def gather_app_entities(self) -> List[str]: - """Returns a list of all the entities involved in any of the states - """ - def gen(): - for settings in deepcopy(self.args['states']): - # dt = self.parse_time(settings.pop('time')) - if (scene := settings.get('scene')): - if isinstance(scene, str): - yield from self.get_entity(scene).get_state('all')['attributes']['entity_id'] - else: - yield from scene.keys() - else: - yield self.args['entity'] - - return list(set(gen())) - @property def all_off(self) -> bool: """"All off" is the logic opposite of "any on" @@ -127,26 +140,23 @@ class RoomController(Hass, Mqtt): """ return any(self.get_state(entity) == 'on' for entity in self.app_entities) - @property - def sleep_bool(self) -> bool: + async def sleep_bool(self) -> bool: if (sleep_var := self.args.get('sleep')): - return self.get_state(sleep_var) == 'on' + return (await self.get_state(sleep_var)) == 'on' else: - # self.log('WARNING') return False - @sleep_bool.setter - def sleep_bool(self, val) -> bool: - if (sleep_var := self.args.get('sleep')): - if isinstance(val, str): - self.set_state(sleep_var, state=val) - elif isinstance(val, bool): - self.set_state(sleep_var, state='on' if val else 'off') - else: - raise ValueError('Sleep variable is undefined') + # @sleep_bool.setter + # def sleep_bool(self, val) -> bool: + # if (sleep_var := self.args.get('sleep')): + # if isinstance(val, str): + # self.set_state(sleep_var, state=val) + # elif isinstance(val, bool): + # self.set_state(sleep_var, state='on' if val else 'off') + # else: + # raise ValueError('Sleep variable is undefined') - @property - def off_duration(self) -> timedelta: + async def off_duration(self) -> timedelta: """Determines the time that the motion sensor has to be clear before deactivating Priority: @@ -156,8 +166,8 @@ class RoomController(Hass, Mqtt): - Sleep - 0 """ - - duration_str = self.current_state().get( + current_state = await self.current_state() + duration_str = current_state.get( 'off_duration', self.args.get('off_duration', '00:00:00') ) @@ -168,9 +178,9 @@ class RoomController(Hass, Mqtt): except Exception: return timedelta() - def activate(self, *args, cause: str = 'unknown', **kwargs): + async def activate(self, *args, cause: str = 'unknown', **kwargs): self.log(f'Activating: {cause}') - scene = self.current_scene() + scene = await self.current_scene() if isinstance(scene, str): self.turn_on(scene) @@ -188,24 +198,24 @@ class RoomController(Hass, Mqtt): elif scene is None: self.log(f'No scene, ignoring...') # Need to act as if the light had just turned off to reset the motion (and maybe other things?) - self.callback_light_off() + # self.callback_light_off() else: self.log(f'ERROR: unknown scene: {scene}') - def activate_all_off(self, *args, **kwargs): + async def activate_all_off(self, *args, **kwargs): """Activate if all of the entities are off """ if self.all_off: self.log(f'Activate all off kwargs: {kwargs}') - self.activate(*args, **kwargs) + await self.activate(*args, **kwargs) else: self.log(f'Skipped activating - everything is not off') - def activate_any_on(self, *args, **kwargs): + async def activate_any_on(self, *args, **kwargs): """Activate if any of the entities are on """ if self.any_on: - self.activate(*args, **kwargs) + await self.activate(*args, **kwargs) else: self.log(f'Skipped activating - everything is off')