Compare commits

6 Commits

Author SHA1 Message Date
John Lancaster
21e1431001 big prune 2024-07-27 17:36:33 -05:00
John Lancaster
2cd02627d1 started motion redo 2024-07-27 16:32:30 -05:00
John Lancaster
37c3a134de added initial state set 2024-07-27 15:33:30 -05:00
John Lancaster
92ddcaa25d added a listen_state for the scheduled transitions 2024-07-27 15:09:26 -05:00
John Lancaster
9af8db9198 handling multiple buttons 2024-07-27 14:53:07 -05:00
John Lancaster
9dfd3e7f38 fixed typos 2024-07-27 14:41:43 -05:00
6 changed files with 110 additions and 149 deletions

View File

@@ -1,6 +1,6 @@
from .room_control import RoomController
from .motion import Motion
from .button import Button from .button import Button
from .door import Door from .door import Door
from .motion import MotionSensor
from .room_control import RoomController
__all__ = ['RoomController', 'Motion', 'Button', 'Door'] __all__ = ['RoomController', 'MotionSensor', 'Button', 'Door']

View File

@@ -1,17 +1,20 @@
import json import json
from dataclasses import dataclass from dataclasses import dataclass
from typing import Any, Dict from typing import TYPE_CHECKING, Any, Dict
from appdaemon.adapi import ADAPI from . import console
if TYPE_CHECKING:
from .room_control import RoomController
@dataclass @dataclass
class Button: class Button:
adapi: ADAPI adapi: 'RoomController'
button_name: str button_name: str
def __post_init__(self): def __post_init__(self):
self.log = self.adapi.log self.logger = console.load_rich_config(self.adapi.name, 'Button', 'DEBUG')
topic = f'zigbee2mqtt/{self.button_name}' topic = f'zigbee2mqtt/{self.button_name}'
self.adapi.listen_event( self.adapi.listen_event(
self.handle_button, self.handle_button,
@@ -20,19 +23,19 @@ class Button:
namespace='mqtt', namespace='mqtt',
button=self.button_name, button=self.button_name,
) )
self.log(f'MQTT topic [topic]{topic}[/] controls [room]{self.adapi.name}[/]') self.logger.info(f'MQTT topic [topic]{topic}[/] controls [room]{self.adapi.name}[/]')
def handle_button(self, event_name: str, data: Dict[str, Any], kwargs: Dict[str, Any]): def handle_button(self, event_name: str, data: Dict[str, Any], kwargs: Dict[str, Any]):
try: try:
payload = json.loads(data['payload']) payload = json.loads(data['payload'])
action = payload['action'] action = payload['action']
except json.JSONDecodeError: except json.JSONDecodeError:
self.log(f'Error decoding JSON from {data["payload"]}', level='ERROR') self.logger.error(f'Error decoding JSON from {data["payload"]}')
except KeyError: except KeyError:
return return
else: else:
if isinstance(action, str) and action != '': if isinstance(action, str) and action != '':
self.log(f'Action: [yellow]{action}[/]') self.logger.info(f'Action: [yellow]{action}[/]')
if action == 'single': if action == 'single':
self.adapi.call_service( self.adapi.call_service(
f'{self.adapi.name}/toggle', namespace='controller', cause='button' f'{self.adapi.name}/toggle', namespace='controller', cause='button'

View File

@@ -1,25 +1,23 @@
from dataclasses import dataclass from dataclasses import dataclass
from logging import Logger, LoggerAdapter from typing import TYPE_CHECKING
from appdaemon.adapi import ADAPI from . import console
if TYPE_CHECKING:
from .room_control import RoomController
@dataclass @dataclass
class Door: class Door:
adapi: ADAPI adapi: 'RoomController'
entity_id: str entity_id: str
def __post_init__(self): def __post_init__(self):
self.logger = LoggerAdapter( self.logger = console.load_rich_config(self.adapi.name, 'Door', 'DEBUG')
self.adapi.logger.logger.getChild('door'), self.adapi.logger.extra
)
self.adapi.listen_state( self.adapi.listen_state(
callback=lambda *args, **kwargs: self.call_service( lambda *args, **kwargs: self.adapi.activate_all_off(cause='door open'),
f'{self.adapi.name}/activate_all_off', namespace='controller'
),
entity_id=self.entity_id, entity_id=self.entity_id,
new='on', new='on',
cause='door open',
) )
self.logger.debug(f'Initialized door for [room]{self.adapi.name}[/]') self.logger.debug(f'Initialized door for [room]{self.adapi.name}[/]')

View File

@@ -4,7 +4,7 @@ from typing import Annotated, Dict, List, Optional, Self
import yaml import yaml
from astral import SunDirection from astral import SunDirection
from pydantic import BaseModel, BeforeValidator, Field, model_validator, root_validator from pydantic import BaseModel, BeforeValidator, Field, model_validator
from pydantic_core import PydanticCustomError from pydantic_core import PydanticCustomError
from rich.console import Console, ConsoleOptions, RenderResult from rich.console import Console, ConsoleOptions, RenderResult
from rich.table import Column, Table from rich.table import Column, Table
@@ -53,10 +53,7 @@ class ControllerStateConfig(BaseModel):
@model_validator(mode='before') @model_validator(mode='before')
def check_args(cls, values): def check_args(cls, values):
time, elevation = values.get('time'), values.get('elevation') if values.get('elevation') is not None and values.get('direction') is None:
# if time is not None and elevation is not None:
# raise PydanticCustomError('bad_time_spec', 'Only one of time or elevation can be set.')
if elevation is not None and 'direction' not in values:
raise PydanticCustomError('no_sun_dir', 'Needs sun direction with elevation') raise PydanticCustomError('no_sun_dir', 'Needs sun direction with elevation')
return values return values
@@ -66,7 +63,7 @@ class ControllerStateConfig(BaseModel):
class RoomControllerConfig(BaseModel): class RoomControllerConfig(BaseModel):
states: List[ControllerStateConfig] = Field(default_factory=list) states: List[ControllerStateConfig] = Field(default_factory=list)
off_duration: Optional[OffDuration] = None off_duration: Optional[OffDuration] = Field(default_factory=datetime.timedelta)
sleep_state: Optional[ControllerStateConfig] = None sleep_state: Optional[ControllerStateConfig] = None
rich: Optional[str] = None rich: Optional[str] = None
manual_mode: Optional[str] = None manual_mode: Optional[str] = None

View File

@@ -1,11 +1,9 @@
import re from dataclasses import dataclass
from datetime import timedelta from datetime import timedelta
from logging import Logger
from typing import TYPE_CHECKING, Literal, Optional from typing import TYPE_CHECKING, Literal, Optional
from appdaemon.entity import Entity from appdaemon.entity import Entity
from appdaemon.plugins.hass.hassapi import Hass from pydantic import BaseModel
from pydantic import BaseModel, ValidationError
from . import console from . import console
@@ -27,151 +25,89 @@ class CallbackEntry(BaseModel):
Callbacks = dict[str, dict[str, CallbackEntry]] Callbacks = dict[str, dict[str, CallbackEntry]]
class Motion(Hass): @dataclass
logger: Logger class MotionSensor:
app: 'RoomController' adapi: 'RoomController'
sensor_entity_id: str
ref_entity_id: str
def __post_init__(self):
self.logger = console.load_rich_config(self.adapi.name, 'Motion', 'DEBUG')
assert self.sensor_entity.exists()
assert self.ref_entity.exists()
base_kwargs = dict(
entity_id=self.ref_entity_id,
immediate=True, # avoids needing to sync the state
)
self.ref_entity.listen_state(self.callback_light_on, attribute='all', **base_kwargs)
self.ref_entity.listen_state(self.callback_light_off, new='off', **base_kwargs)
@property @property
def sensor(self) -> Entity: def sensor_entity(self) -> Entity:
return self.get_entity(self.args['sensor']) return self.adapi.get_entity(self.sensor_entity_id)
@property @property
def sensor_state(self) -> bool: def sensor_state(self) -> bool:
return self.sensor.state == 'on' return self.sensor_entity.get_state() == 'on'
@property @property
def ref_entity(self) -> Entity: def ref_entity(self) -> Entity:
return self.get_entity(self.args['ref_entity']) return self.adapi.get_entity(self.ref_entity_id)
@property @property
def ref_entity_state(self) -> bool: def ref_state(self) -> bool:
return self.ref_entity.get_state() == 'on' return self.ref_entity.get_state() == 'on'
@property @property
def state_mismatch(self) -> bool: def state_mismatch(self) -> bool:
return self.sensor_state != self.ref_entity_state return self.sensor_state != self.ref_state
def initialize(self): def callback_light_on(self, entity: str, attribute: str, old: str, new: str, kwargs: dict):
self.app: 'RoomController' = self.get_app(self.args['app']) """Called when the light turns on"""
self.logger = console.load_rich_config(self.app.name, type(self).__name__) if new['state'] == 'on':
self.logger.debug(f'Detected {entity} turning on')
duration = self.adapi.off_duration()
self.listen_motion_off(duration)
assert self.entity_exists(self.args['sensor']) def callback_light_off(self, entity: str, attribute: str, old: str, new: str, kwargs: dict):
assert self.entity_exists(self.args['ref_entity']) """Called when the light turns off"""
self.logger.debug(f'Detected {entity} turning off')
base_kwargs = dict( self.listen_motion_on()
entity_id=self.ref_entity.entity_id,
immediate=True, # avoids needing to sync the state
)
if self.state_mismatch:
self.log(
f'Sensor is {self.sensor_state} ' f'and light is {self.ref_entity_state}',
level='WARNING',
)
if self.sensor_state:
self.app.activate(kwargs={'cause': f'Syncing state with {self.sensor.entity_id}'})
# don't need to await these because they'll already get turned into a task by the utils.sync_wrapper decorator
self.listen_state(
**base_kwargs,
attribute='brightness',
callback=self.callback_light_on,
)
self.listen_state(**base_kwargs, new='off', callback=self.callback_light_off)
for handle, cb in self.callbacks():
self.log(f'Handle [yellow]{handle[:4]}[/]: {cb.function}', level='DEBUG')
self.log(f'Initialized [bold green]{type(self).__name__}[/]')
def callbacks(self):
"""Returns a dictionary of validated CallbackEntry objects that are associated with this app"""
self_callbacks = self.get_callback_entries().get(self.name, {})
for handle, cb_dict in self_callbacks.items():
try:
yield handle, CallbackEntry.model_validate(cb_dict)
except ValidationError as e:
self.logger.error('Error parsing callback dictionary')
self.logger.error(e)
def listen_motion_on(self): def listen_motion_on(self):
"""Sets up the motion on callback to activate the room""" """Sets up the motion on callback to activate the room"""
self.cancel_motion_callback() # self.cancel_motion_callback()
self.listen_state( self.adapi.listen_state(
callback=self.app.activate_all_off, lambda *args, **kwargs: self.adapi.activate_all_off(cause='motion on'),
entity_id=self.sensor.entity_id, entity_id=self.sensor_entity_id,
new='on', new='on',
oneshot=True, oneshot=True,
cause='motion on',
) )
self.log(f'Waiting for sensor motion on [friendly_name]{self.sensor.friendly_name}[/]') self.logger.info(
f'Waiting for sensor motion on [friendly_name]{self.sensor_entity.friendly_name}[/]'
)
if self.sensor_state: if self.sensor_state:
self.log( self.logger.warning(
f'Sensor [friendly_name]{self.sensor.friendly_name}[/] is already on', f'Sensor [friendly_name]{self.sensor_entity.friendly_name}[/] is already on',
level='WARNING',
) )
def listen_motion_off(self, duration: timedelta): def listen_motion_off(self, duration: timedelta):
"""Sets up the motion off callback to deactivate the room""" """Sets up the motion off callback to deactivate the room"""
self.cancel_motion_callback() # self.cancel_motion_callback()
self.listen_state( self.adapi.listen_state(
callback=self.app.deactivate, lambda *args, **kwargs: self.adapi.deactivate(cause='motion off'),
entity_id=self.sensor.entity_id, entity_id=self.sensor_entity_id,
new='off', new='off',
duration=duration.total_seconds(), duration=duration.total_seconds(),
oneshot=True, oneshot=True,
cause='motion off',
) )
self.log( self.logger.debug(
f'Waiting for sensor [friendly_name]{self.sensor.friendly_name}[/] to be clear for {duration}' f'Waiting for sensor [friendly_name]{self.sensor_entity.friendly_name}[/] to be clear for {duration}'
) )
if not self.sensor_state: if not self.sensor_state:
self.log( self.logger.warning(
f'Sensor [friendly_name]{self.sensor.friendly_name}[/] is currently off', f'Sensor [friendly_name]{self.sensor_entity.friendly_name}[/] is currently off',
level='WARNING',
)
def callback_light_on(self, entity=None, attribute=None, old=None, new=None, kwargs=None):
"""Called when the light turns on"""
if new is not None:
self.log(f'Detected {entity} turning on', level='DEBUG')
duration = self.app.off_duration()
self.listen_motion_off(duration)
def callback_light_off(self, entity=None, attribute=None, old=None, new=None, kwargs=None):
"""Called when the light turns off"""
self.log(f'Detected {entity} turning off', level='DEBUG')
self.listen_motion_on()
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 handle, info in callbacks.items()
if app_name == name
}
return callbacks
def get_sensor_callbacks(self):
return {
handle: info
for handle, info in self.get_app_callbacks().items()
if info['entity'] == self.sensor.entity_id
}
def cancel_motion_callback(self):
callbacks = self.get_sensor_callbacks()
# self.log(f'Found {len(callbacks)} callbacks for {self.sensor.entity_id}')
for handle, info in callbacks.items():
entity = info['entity']
kwargs = info['kwargs']
if (m := re.match('new=(?P<new>.*?)\s', kwargs)) is not None:
new = m.group('new')
self.cancel_listen_state(handle)
self.log(
f'cancelled callback for sensor {entity} turning {new}',
level='DEBUG',
) )

View File

@@ -13,6 +13,7 @@ from . import console
from .button import Button from .button import Button
from .door import Door from .door import Door
from .model import ControllerStateConfig, RoomControllerConfig from .model import ControllerStateConfig, RoomControllerConfig
from .motion import MotionSensor
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -67,6 +68,9 @@ class RoomController(Hass):
self.register_service( self.register_service(
f'{self.name}/activate_all_off', self._service_activate_all_off, namespace='controller' f'{self.name}/activate_all_off', self._service_activate_all_off, namespace='controller'
) )
self.register_service(
f'{self.name}/activate_any_on', self._service_activate_any_on, namespace='controller'
)
self.register_service( self.register_service(
f'{self.name}/deactivate', self._service_deactivate, namespace='controller' f'{self.name}/deactivate', self._service_deactivate, namespace='controller'
) )
@@ -79,12 +83,29 @@ class RoomController(Hass):
if button := self.args.get('button'): if button := self.args.get('button'):
if isinstance(button, str): if isinstance(button, str):
self.button = Button(self, button_name=button) self.button = Button(self, button_name=button)
elif isinstance(button, list) and all(isinstance(b, str) for b in button):
self.button = [Button(self, button_name=b) for b in button]
if door := self.args.get('door'): if door := self.args.get('door'):
if isinstance(door, str): if isinstance(door, str):
self.log('door--') self.log('door--')
self.door = Door(self, entity_id=door) self.door = Door(self, entity_id=door)
if motion := self.args.get('motion'):
self.motion = MotionSensor(
self, sensor_entity_id=motion['sensor'], ref_entity_id=motion['ref_entity']
)
for state in sorted(self._room_config.states, key=lambda s: s.time, reverse=True):
if isinstance(state.time, datetime.datetime):
t = state.time.time()
else:
t = state.time
if t < self.get_now().time():
self.log(f'Initial state: {state.time}', level='DEBUG')
self.set_controller_scene(state)
break
self.log(f'Initialized [bold green]{type(self).__name__}[/]') self.log(f'Initialized [bold green]{type(self).__name__}[/]')
def terminate(self): def terminate(self):
@@ -137,7 +158,15 @@ class RoomController(Hass):
except Exception as e: except Exception as e:
self.log(f'Failed with {type(e)}: {e}') self.log(f'Failed with {type(e)}: {e}')
self.state_entity.listen_state(
lambda *args, **kwargs: self.call_service(
f'{self.name}/activate_any_on', namespace='controller', cause='state transition'
),
attribute='all',
)
def set_controller_scene(self, state: ControllerStateConfig): def set_controller_scene(self, state: ControllerStateConfig):
"""Sets the internal state for the app. Only used by the scheduled transition"""
try: try:
self.state_entity.set_state(attributes=state.model_dump()) self.state_entity.set_state(attributes=state.model_dump())
except Exception: except Exception:
@@ -166,7 +195,7 @@ class RoomController(Hass):
self.call_service(f'{self.name}/activate', namespace='controller', **kwargs) self.call_service(f'{self.name}/activate', namespace='controller', **kwargs)
def _service_activate(self, namespace: str, domain: str, service: str, kwargs: Dict[str, Any]): def _service_activate(self, namespace: str, domain: str, service: str, kwargs: Dict[str, Any]):
self.log(f'Custom kwargs: {kwargs}', level='DEBUG') # self.log(f'Custom kwargs: {kwargs}', level='DEBUG')
state = self.current_state() state = self.current_state()
if isinstance(state.scene, str): if isinstance(state.scene, str):
self.turn_on(state.scene) self.turn_on(state.scene)
@@ -258,10 +287,8 @@ class RoomController(Hass):
- Sleep - 0 - Sleep - 0
""" """
sleep_mode_active = self.sleep_bool() if sleep_active := self.sleep_bool():
if sleep_mode_active: self.log(f'Sleeping mode active: {sleep_active}', level='DEBUG')
self.log(f'Sleeping mode active: {sleep_mode_active}')
return datetime.timedelta() return datetime.timedelta()
else: else:
now = now or self.get_now().time() return self.current_state().off_duration or self._room_config.off_duration
return self._room_config.current_off_duration(now)