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 .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
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
class Button:
adapi: ADAPI
adapi: 'RoomController'
button_name: str
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}'
self.adapi.listen_event(
self.handle_button,
@@ -20,19 +23,19 @@ class Button:
namespace='mqtt',
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]):
try:
payload = json.loads(data['payload'])
action = payload['action']
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:
return
else:
if isinstance(action, str) and action != '':
self.log(f'Action: [yellow]{action}[/]')
self.logger.info(f'Action: [yellow]{action}[/]')
if action == 'single':
self.adapi.call_service(
f'{self.adapi.name}/toggle', namespace='controller', cause='button'

View File

@@ -1,25 +1,23 @@
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
class Door:
adapi: ADAPI
adapi: 'RoomController'
entity_id: str
def __post_init__(self):
self.logger = LoggerAdapter(
self.adapi.logger.logger.getChild('door'), self.adapi.logger.extra
)
self.logger = console.load_rich_config(self.adapi.name, 'Door', 'DEBUG')
self.adapi.listen_state(
callback=lambda *args, **kwargs: self.call_service(
f'{self.adapi.name}/activate_all_off', namespace='controller'
),
lambda *args, **kwargs: self.adapi.activate_all_off(cause='door open'),
entity_id=self.entity_id,
new='on',
cause='door open',
)
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
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 rich.console import Console, ConsoleOptions, RenderResult
from rich.table import Column, Table
@@ -53,10 +53,7 @@ class ControllerStateConfig(BaseModel):
@model_validator(mode='before')
def check_args(cls, values):
time, elevation = values.get('time'), values.get('elevation')
# 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:
if values.get('elevation') is not None and values.get('direction') is None:
raise PydanticCustomError('no_sun_dir', 'Needs sun direction with elevation')
return values
@@ -66,7 +63,7 @@ class ControllerStateConfig(BaseModel):
class RoomControllerConfig(BaseModel):
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
rich: 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 logging import Logger
from typing import TYPE_CHECKING, Literal, Optional
from appdaemon.entity import Entity
from appdaemon.plugins.hass.hassapi import Hass
from pydantic import BaseModel, ValidationError
from pydantic import BaseModel
from . import console
@@ -27,151 +25,89 @@ class CallbackEntry(BaseModel):
Callbacks = dict[str, dict[str, CallbackEntry]]
class Motion(Hass):
logger: Logger
app: 'RoomController'
@dataclass
class MotionSensor:
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
def sensor(self) -> Entity:
return self.get_entity(self.args['sensor'])
def sensor_entity(self) -> Entity:
return self.adapi.get_entity(self.sensor_entity_id)
@property
def sensor_state(self) -> bool:
return self.sensor.state == 'on'
return self.sensor_entity.get_state() == 'on'
@property
def ref_entity(self) -> Entity:
return self.get_entity(self.args['ref_entity'])
return self.adapi.get_entity(self.ref_entity_id)
@property
def ref_entity_state(self) -> bool:
def ref_state(self) -> bool:
return self.ref_entity.get_state() == 'on'
@property
def state_mismatch(self) -> bool:
return self.sensor_state != self.ref_entity_state
return self.sensor_state != self.ref_state
def initialize(self):
self.app: 'RoomController' = self.get_app(self.args['app'])
self.logger = console.load_rich_config(self.app.name, type(self).__name__)
def callback_light_on(self, entity: str, attribute: str, old: str, new: str, kwargs: dict):
"""Called when the light turns on"""
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'])
assert self.entity_exists(self.args['ref_entity'])
base_kwargs = dict(
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 callback_light_off(self, entity: str, attribute: str, old: str, new: str, kwargs: dict):
"""Called when the light turns off"""
self.logger.debug(f'Detected {entity} turning off')
self.listen_motion_on()
def listen_motion_on(self):
"""Sets up the motion on callback to activate the room"""
self.cancel_motion_callback()
self.listen_state(
callback=self.app.activate_all_off,
entity_id=self.sensor.entity_id,
# self.cancel_motion_callback()
self.adapi.listen_state(
lambda *args, **kwargs: self.adapi.activate_all_off(cause='motion on'),
entity_id=self.sensor_entity_id,
new='on',
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:
self.log(
f'Sensor [friendly_name]{self.sensor.friendly_name}[/] is already on',
level='WARNING',
self.logger.warning(
f'Sensor [friendly_name]{self.sensor_entity.friendly_name}[/] is already on',
)
def listen_motion_off(self, duration: timedelta):
"""Sets up the motion off callback to deactivate the room"""
self.cancel_motion_callback()
self.listen_state(
callback=self.app.deactivate,
entity_id=self.sensor.entity_id,
# self.cancel_motion_callback()
self.adapi.listen_state(
lambda *args, **kwargs: self.adapi.deactivate(cause='motion off'),
entity_id=self.sensor_entity_id,
new='off',
duration=duration.total_seconds(),
oneshot=True,
cause='motion off',
)
self.log(
f'Waiting for sensor [friendly_name]{self.sensor.friendly_name}[/] to be clear for {duration}'
self.logger.debug(
f'Waiting for sensor [friendly_name]{self.sensor_entity.friendly_name}[/] to be clear for {duration}'
)
if not self.sensor_state:
self.log(
f'Sensor [friendly_name]{self.sensor.friendly_name}[/] is currently off',
level='WARNING',
self.logger.warning(
f'Sensor [friendly_name]{self.sensor_entity.friendly_name}[/] is currently off',
)
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 .door import Door
from .model import ControllerStateConfig, RoomControllerConfig
from .motion import MotionSensor
logger = logging.getLogger(__name__)
@@ -67,6 +68,9 @@ class RoomController(Hass):
self.register_service(
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(
f'{self.name}/deactivate', self._service_deactivate, namespace='controller'
)
@@ -79,12 +83,29 @@ class RoomController(Hass):
if button := self.args.get('button'):
if isinstance(button, str):
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 isinstance(door, str):
self.log('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__}[/]')
def terminate(self):
@@ -137,7 +158,15 @@ class RoomController(Hass):
except Exception as 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):
"""Sets the internal state for the app. Only used by the scheduled transition"""
try:
self.state_entity.set_state(attributes=state.model_dump())
except Exception:
@@ -166,7 +195,7 @@ class RoomController(Hass):
self.call_service(f'{self.name}/activate', namespace='controller', **kwargs)
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()
if isinstance(state.scene, str):
self.turn_on(state.scene)
@@ -258,10 +287,8 @@ class RoomController(Hass):
- Sleep - 0
"""
sleep_mode_active = self.sleep_bool()
if sleep_mode_active:
self.log(f'Sleeping mode active: {sleep_mode_active}')
if sleep_active := self.sleep_bool():
self.log(f'Sleeping mode active: {sleep_active}', level='DEBUG')
return datetime.timedelta()
else:
now = now or self.get_now().time()
return self._room_config.current_off_duration(now)
return self.current_state().off_duration or self._room_config.off_duration