Files
ad-nix/apps/controller.py
2023-04-23 23:40:45 -05:00

288 lines
10 KiB
Python

from dataclasses import dataclass, field
from datetime import datetime, timedelta
import logging
from typing import List
from appdaemon.entity import Entity
from appdaemon.plugins.hass.hassapi import Hass
from daylight_adjuster import DaylightAdjuster
@dataclass(init=False)
class ControllerEntities(Hass):
entities: List[Entity]
def initialize(self):
# assign fields
for arg, val in self.args.items():
if arg not in ['class', 'module']:
setattr(self, arg, val)
# self.log(f'Set {arg} to {val}')
for entity in self.entities:
assert self.entity_exists(entity), f'{entity} does not exist'
self.entities = [self.get_entity(e) for e in self.entities]
@dataclass(init=False)
class ControllerRoomLights(ControllerEntities):
sleep: str
def initialize(self):
super().initialize()
self.log(f'Initialized light controller for {[e.friendly_name for e in self.entities]}')
self.register_service(f'{self.name}/activate', self.activate)
self.register_service(f'{self.name}/deactivate', self.deactivate)
def activate(self, namespace: str = None, domain: str = None, service=None, kwargs=None):
# if self.is_sleeping:
# self.log(f'Sleep mode is on, returning early')
# return
for entity in self.entities:
self.log(f'Turning on {entity.name}')
entity.turn_on()
def deactivate(self, namespace: str = None, domain: str = None, service=None, kwargs=None):
self.log(self.entities)
for entity in self.entities:
self.log(f'Turning off {entity.name}')
entity.turn_off()
@property
def state(self) -> bool:
return any([e.get_state() == 'on' for e in self.entities])
@property
def is_sleeping(self) -> bool:
if 'sleep' in self.args:
return self.get_entity(self.args['sleep']).is_state('on')
else:
return False
@is_sleeping.setter
def is_sleeping(self, val: bool):
if 'sleep' in self.args:
self.get_entity(self.args['sleep']).set_state(state='on' if val else 'off')
@dataclass(init=False)
class ControllerMotion(ControllerEntities):
room: ControllerRoomLights
off_duration: timedelta
def initialize(self):
super().initialize()
# self.log('Motion Controller init')
# convert room to App
self.room: ControllerRoomLights = self.get_app(self.room)
# convert off_duration
try:
hours, minutes, seconds = map(int, self.args['off_duration'].split(':'))
self.off_duration = timedelta(hours=hours, minutes=minutes, seconds=seconds)
except Exception:
self.off_duration = timedelta()
if self.current_state:
self.room.activate()
self.listen_motion_off()
self.listen_motion_on()
@property
def current_state(self) -> bool:
return any(e.get_state() == 'on' for e in self.entities)
def listen_motion_on(self):
self.listen_state(
callback=self.callback_motion_on,
entity_id=[e.entity_id for e in self.entities],
new='on',
)
self.log(f'Waiting for motion on {[e.friendly_name for e in self.entities]} to turn on room {self.room.name}')
def listen_motion_off(self):
self.listen_state(
callback=self.callback_motion_off,
entity_id=[e.entity_id for e in self.entities],
new='off',
duration=self.off_duration.total_seconds(),
)
self.log(f'Waiting for motion off {[e.friendly_name for e in self.entities]} for {self.off_duration}')
def callback_motion_on(self, entity, attribute, old, new, kwargs):
self.log(f'Motion detected on {self.friendly_name(entity)}')
if not self.room.is_sleeping:
self.room.activate()
def callback_motion_off(self, entity, attribute, old, new, kwargs):
self.log(f'Motion stopped on {self.friendly_name(entity)} for {self.off_duration}')
self.room.deactivate()
@dataclass(init=False)
class ControllerButton(Hass):
room: ControllerRoomLights
buttons: List[str]
def initialize(self):
self.buttons = self.args['buttons']
# convert room to App
self.room: ControllerRoomLights = self.get_app(self.args['room'])
for button in self.buttons:
self.listen_event(
self.callback_button,
event='deconz_event',
id=button,
)
self.log(f'Listening to presses on button ID={button}')
def callback_button(self, event_name, data, kwargs):
# single press
if data['event'] == 1002:
self.log(f"Single press: {data['id']}")
if self.room.state:
self.room.deactivate()
else:
self.room.activate()
# double click
elif data['event'] == 1004:
self.log(f'{data["id"]} double click')
self.room.is_sleeping = not self.room.is_sleeping
if not self.room.is_sleeping:
self.room.activate()
@dataclass(init=False)
class ControllerDaylight(Hass):
room: ControllerRoomLights
entities: List[str]
latitude: float
longitude: float
def initialize(self):
# convert room to App
self.room: ControllerRoomLights = self.get_app(self.args['room'])
# convert entities
for entity in self.args['entities']:
assert self.entity_exists(entity), f'{entity} does not exist'
self.entities = [self.get_entity(e) for e in self.args['entities']]
# self.log(self.entities)
# create Adjuster
self.adjuster = DaylightAdjuster(
latitude=self.args['latitude'],
longitude=self.args['longitude'],
periods=self.args['periods'],
resolution=500
)
# self.log(self.adjuster)
self.listen_state(callback=self.handle_state_change,
entity_id=[e.entity_id for e in self.entities],
attribute='brightness')
self.listen_state(callback=self.handle_state_change,
entity_id=[e.entity_id for e in self.entities],
attribute='color_temp')
ents = [e.friendly_name for e in self.entities]
if len(ents) > 1:
ents[-1] = f'and {ents[-1]}'
delim = ', ' if len(ents) >= 3 else ' '
self.log(f'Listening for state changes on {delim.join(ents)}')
self.run_every(
callback=self.update_sensors,
start='now',
interval=self.args.get('interval', 5)
)
for entity in self.entities:
self.run_every(
callback=self.ongoing_adjustment,
start='now',
interval=self.args.get('interval', 5),
entity=entity
)
if (entity_name := self.args.get('enable')) is not None:
self.enable_entity: Entity = self.get_entity(entity_name)
self.log(f'enabled by {self.enable_entity.friendly_name}[{entity_name}]')
self.listen_state(
callback=lambda entity, attribute, old, new, kwargs: self.ongoing_adjustment({'entity': entity}),
entity_id=entity_name,
new='on'
)
def handle_state_change(self, entity=None, attribute=None, old=None, new=None, kwargs=None):
if not self.matching_state(entity):
self.log(f'{entity}.{attribute}: {old} -> {new}')
self.log(f'State does not match adjuster settings, disabling adjustments')
self.enabled = False
def matching_state(self, entity_id: str) -> bool:
"""Checks whether the current state of the light matches the settings from the DaylightAdjuster
Args:
entity_id (str): full entity ID
Returns:
bool
"""
state = self.get_state(entity_id=entity_id, attribute='all')['attributes']
settings = self.adjuster.current_settings
try:
state = {s: state[s] for s in settings.keys()}
except KeyError:
for s in settings.keys():
if s not in state:
self.log(f'{s} not in {state}')
return False
else:
valid = all((state[s] == val) for s, val in settings.items())
# if not valid:
# for s, val in settings.items():
# if state[s] != val:
# self.log(f'{entity_id}.{s}: {state[s]} != {val}')
return valid
@property
def enabled(self) -> bool:
if hasattr(self, 'enable_entity'):
return self.enable_entity.is_state('on')
else:
return True
@enabled.setter
def enabled(self, new: bool) -> bool:
self.enable_entity.set_state(state='on' if new else 'off')
def ongoing_adjustment(self, kwargs=None):
if self.enabled:
entity: Entity = self.get_entity(kwargs['entity'])
if entity.get_state() == 'on':
self.log(f'Ongoing adjustment for {entity.friendly_name}')
settings = self.adjuster.current_settings
matching = self.matching_state(entity_id=kwargs['entity'])
if not matching and not self.room.is_sleeping:
self.turn_on(entity_id=kwargs['entity'], **settings)
self.log(f'Adjusted {self.friendly_name(kwargs["entity"])} with {settings}')
else:
self.log(f'{entity.friendly_name} is off - no adjustment')
else:
self.log(f'App disabled by {self.enable_entity.friendly_name}')
def update_sensors(self, kwargs):
for key, val in self.adjuster.current_settings.items():
id = f'sensor.{self.name}_{key}'
self.set_state(
entity_id=id, state=val,
attributes={
'friendly_name': f'Daylight, {key}, {self.name}',
'state_class': 'measurement'})