12 Commits
events ... main

Author SHA1 Message Date
John Lancaster
72b4b7d72e pyproject updates 2025-11-16 09:26:56 -06:00
John Lancaster
b1638be692 added entity callbacks 2024-09-12 21:15:30 -05:00
John Lancaster
8b7294b445 broke out virtual button 2024-09-01 21:07:29 -05:00
John Lancaster
e4dada011c added virtual buttons 2024-09-01 20:38:47 -05:00
John Lancaster
e3186f1b5e removed unused import 2024-08-27 20:45:48 -05:00
John Lancaster
2180e544c0 another fix 2024-08-27 20:43:01 -05:00
John Lancaster
82f43cd029 simplifications 2024-08-27 19:24:38 -05:00
John Lancaster
1b3fc4afb7 changes for use_dictionary_unpacking 2024-08-27 00:41:25 -05:00
John Lancaster
0f58ac4cc6 fixed reconstruction of sun direction from state with a new field validator 2024-07-31 23:26:09 -05:00
John Lancaster
b278a3cda1 changed default of load_rich_config 2024-07-27 20:52:08 -05:00
John Lancaster
e4fcd757ce added log_level config per app 2024-07-27 20:48:57 -05:00
John Lancaster
7cde3c75d8 changed to use services 2024-07-27 17:37:33 -05:00
7 changed files with 153 additions and 102 deletions

View File

@@ -12,16 +12,17 @@ dependencies = [
"ruff>=0.4.2", "ruff>=0.4.2",
] ]
readme = "README.md" readme = "README.md"
requires-python = ">= 3.8,<3.12" requires-python = ">= 3.10,<3.13"
[tool.setuptools] [build-system]
include-package-data = true requires = ["hatchling"]
build-backend = "hatchling.build"
# [tool.setuptools.package-data] [tool.hatch.metadata]
# mypkg = ["*.yaml"] allow-direct-references = true
[tool.setuptools.data-files] [tool.hatch.build.targets.wheel]
config = ["config/default_config.yaml"] packages = ["src/room_control"]
[tool.ruff.format] [tool.ruff.format]
quote-style = 'single' quote-style = 'single'

View File

@@ -2,6 +2,8 @@ import json
from dataclasses import dataclass from dataclasses import dataclass
from typing import TYPE_CHECKING, Any, Dict from typing import TYPE_CHECKING, Any, Dict
from appdaemon.entity import Entity
from . import console from . import console
if TYPE_CHECKING: if TYPE_CHECKING:
@@ -14,7 +16,7 @@ class Button:
button_name: str button_name: str
def __post_init__(self): def __post_init__(self):
self.logger = console.load_rich_config(self.adapi.name, 'Button', 'DEBUG') self.logger = console.load_rich_config(self.adapi.name, 'Button')
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,
@@ -25,18 +27,69 @@ class Button:
) )
self.logger.info(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]):
if event_name == 'appd_started':
return
# self.logger.info(f'Button callback: {event_name}, {data}')
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.logger.error(f'Error decoding JSON from {data["payload"]}') self.logger.error(f'Error decoding JSON from {data["payload"]}')
except KeyError:
return
else: else:
self.do_action(action)
def do_action(self, action: str):
"""Action can be single, double, or others"""
if isinstance(action, str) and action != '': if isinstance(action, str) and action != '':
self.logger.info(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'
) )
@dataclass
class VirtualButton(Button):
def __post_init__(self):
self.logger = console.load_rich_config(self.adapi.name, 'Button')
friendly_name = self.adapi.name.title().replace('_', ' ') + ' Button'
kwargs = {'entity_id': self.eid, 'friendly_name': friendly_name}
if not self.adapi.entity_exists(self.eid):
self.adapi.set_state(state=self.adapi.get_now(), **kwargs)
self.logger.info(f'Created entity [green]{self.eid}[/]')
else:
self.adapi.set_state(**kwargs)
self.logger.info(f'Set friendly name [green]{self.virtual_entity.friendly_name}[/]')
self.adapi.listen_event(self.handle_virtual_button, entity_id=self.eid)
@property
def eid(self) -> str:
return f'input_button.{self.adapi.name}'
@property
def virtual_entity(self) -> Entity:
return self.adapi.get_entity(self.eid)
def handle_virtual_button(
self, event_name: str, data: Dict[str, Any], **kwargs: Dict[str, Any]
):
if (
event_name == 'call_service'
and data.get('service') == 'press'
and (sd := data.get('service_data'))
and sd.get('entity_id') == self.eid
):
try:
if data['service_data']['entity_id'] == self.eid:
self.logger.info(f'Virtual button press: {event_name}')
# self.virtual_entity.set_state(state=datetime.now())
self.virtual_entity.set_state(state=self.adapi.get_now())
self.do_action('single')
except KeyError as e:
self.logger.error(f'Bad data from {event_name}: {json.dumps(data, indent=4)}')

View File

@@ -46,7 +46,7 @@ class RCHighlighter(RegexHighlighter):
def load_rich_config( def load_rich_config(
room: str = None, component: str = None, level: str = 'INFO' room: str = None, component: str = None, level: str = None
) -> logging.LoggerAdapter: ) -> logging.LoggerAdapter:
logger_name = f'Appdaemon.{room}' logger_name = f'Appdaemon.{room}'
@@ -64,7 +64,6 @@ def load_rich_config(
logger_name: { logger_name: {
'handlers': ['rich' if component is None else 'rich_component'], 'handlers': ['rich' if component is None else 'rich_component'],
'propagate': False, 'propagate': False,
'level': level,
} }
} }
@@ -73,6 +72,9 @@ def load_rich_config(
if component is not None: if component is not None:
extra['component'] = component extra['component'] = component
if level is not None:
RICH_CFG['loggers'][logger_name]['level'] = level
logging.config.dictConfig(RICH_CFG) logging.config.dictConfig(RICH_CFG)
logger = logging.getLogger(logger_name) logger = logging.getLogger(logger_name)
adapter = logging.LoggerAdapter(logger, extra) adapter = logging.LoggerAdapter(logger, extra)

View File

@@ -13,7 +13,7 @@ class Door:
entity_id: str entity_id: str
def __post_init__(self): def __post_init__(self):
self.logger = console.load_rich_config(self.adapi.name, 'Door', 'DEBUG') self.logger = console.load_rich_config(self.adapi.name, 'Door')
self.adapi.listen_state( self.adapi.listen_state(
lambda *args, **kwargs: self.adapi.activate_all_off(cause='door open'), lambda *args, **kwargs: self.adapi.activate_all_off(cause='door open'),

View File

@@ -1,10 +1,10 @@
import datetime import datetime
from pathlib import Path from pathlib import Path
from typing import Annotated, Dict, List, Optional, Self from typing import Annotated, Dict, List, Optional, Self, Union
import yaml import yaml
from astral import SunDirection from astral import SunDirection
from pydantic import BaseModel, BeforeValidator, Field, model_validator from pydantic import BaseModel, BeforeValidator, Field, field_validator, 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
@@ -47,7 +47,7 @@ class ApplyKwargs(BaseModel):
class ControllerStateConfig(BaseModel): class ControllerStateConfig(BaseModel):
time: Optional[str | datetime.time | datetime.datetime] = None time: Optional[str | datetime.time | datetime.datetime] = None
elevation: Optional[float] = None elevation: Optional[float] = None
direction: Optional[Annotated[SunDirection, BeforeValidator(str_to_direction)]] = None direction: Optional[SunDirection] = None
off_duration: Optional[OffDuration] = None off_duration: Optional[OffDuration] = None
scene: dict[str, State] | str = Field(default_factory=dict) scene: dict[str, State] | str = Field(default_factory=dict)
@@ -57,16 +57,35 @@ class ControllerStateConfig(BaseModel):
raise PydanticCustomError('no_sun_dir', 'Needs sun direction with elevation') raise PydanticCustomError('no_sun_dir', 'Needs sun direction with elevation')
return values return values
@field_validator('direction', mode='before')
@classmethod
def check_sun_dir(cls, val: int | str | SunDirection | None) -> SunDirection:
if isinstance(val, str):
print(f'Str sun direction: {val}')
return str_to_direction(val)
elif isinstance(val, int):
return SunDirection.SETTING if val < 0 else SunDirection.RISING
elif isinstance(val, SunDirection):
return val
def to_apply_kwargs(self, transition: int = None): def to_apply_kwargs(self, transition: int = None):
return ApplyKwargs(entities=self.scene, transition=transition).model_dump(exclude_none=True) return ApplyKwargs(entities=self.scene, transition=transition).model_dump(exclude_none=True)
class MotionSensorConfig(BaseModel):
sensor: str
ref_entity: str
class RoomControllerConfig(BaseModel): class RoomControllerConfig(BaseModel):
states: List[ControllerStateConfig] = Field(default_factory=list) states: List[ControllerStateConfig] = Field(default_factory=list)
off_duration: Optional[OffDuration] = Field(default_factory=datetime.timedelta) 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
button: Optional[Union[str, List[str]]] = None
motion: Optional[MotionSensorConfig] = None
log_level: Optional[str] = None
@classmethod @classmethod
def from_yaml(cls: Self, yaml_path: Path) -> Self: def from_yaml(cls: Self, yaml_path: Path) -> Self:
@@ -95,38 +114,3 @@ class RoomControllerConfig(BaseModel):
] ]
table.add_row(state.time.strftime('%I:%M:%S %p'), '\n'.join(lines)) table.add_row(state.time.strftime('%I:%M:%S %p'), '\n'.join(lines))
yield table yield table
def sort_states(self):
"""Should only be called after all the times have been resolved"""
assert all(
isinstance(state.time, datetime.time) for state in self.states
), 'Times have not all been resolved yet'
self.states = sorted(self.states, key=lambda s: s.time, reverse=True)
def current_state(self, now: datetime.time) -> ControllerStateConfig:
self.sort_states()
for state in self.states:
if state.time <= now:
return state
else:
return self.states[0]
def current_scene(self, now: datetime.time) -> Dict:
state = self.current_state(now)
return state.scene
def current_off_duration(self, now: datetime.time) -> datetime.timedelta:
state = self.current_state(now)
if state.off_duration is None:
if self.off_duration is None:
raise ValueError('Need an off duration')
else:
return self.off_duration
else:
return state.off_duration
class ButtonConfig(BaseModel):
app: str
button: str | List[str]
ref_entity: str

View File

@@ -32,17 +32,28 @@ class MotionSensor:
ref_entity_id: str ref_entity_id: str
def __post_init__(self): def __post_init__(self):
self.logger = console.load_rich_config(self.adapi.name, 'Motion', 'DEBUG') self.logger = console.load_rich_config(self.adapi.name, 'Motion')
assert self.sensor_entity.exists() assert self.sensor_entity.exists()
assert self.ref_entity.exists() assert self.ref_entity.exists()
base_kwargs = dict( self.ref_entity.listen_state(self.light_state_callback, immediate=True)
entity_id=self.ref_entity_id, self.match_new_state(new=self.sensor_entity.get_state())
immediate=True, # avoids needing to sync the state self.logger.info('Initialized motion sensor')
)
self.ref_entity.listen_state(self.callback_light_on, attribute='all', **base_kwargs) def entity_callbacks(self, entity_id: str | None = None) -> dict[str, dict]:
self.ref_entity.listen_state(self.callback_light_off, new='off', **base_kwargs) callbacks = self.adapi.get_callback_entries()
if self.adapi.name in callbacks:
return {
handle: cb
for handle, cb in callbacks[self.adapi.name].items()
if cb.get('entity') == entity_id
}
else:
return {}
def sensor_callbacks(self) -> dict[str, dict]:
return self.entity_callbacks(entity_id=self.sensor_entity_id)
@property @property
def sensor_entity(self) -> Entity: def sensor_entity(self) -> Entity:
@@ -64,47 +75,50 @@ class MotionSensor:
def state_mismatch(self) -> bool: def state_mismatch(self) -> bool:
return self.sensor_state != self.ref_state return self.sensor_state != self.ref_state
def callback_light_on(self, entity: str, attribute: str, old: str, new: str, kwargs: dict): def light_state_callback(self, entity: str, attribute: str, old: str, new: str, **kwargs: dict):
"""Called when the light turns on""" for handle in self.sensor_callbacks():
if new['state'] == 'on': self.adapi.cancel_listen_state(handle)
self.logger.debug(f'Detected {entity} turning on') self.match_new_state(new)
def match_new_state(self, new: Literal['on', 'off']):
match new:
case 'on':
duration = self.adapi.off_duration() duration = self.adapi.off_duration()
self.listen_motion_off(duration) self.listen_motion_off(duration)
case 'off':
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() self.listen_motion_on()
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.adapi.listen_state( self.sensor_entity.listen_state(
lambda *args, **kwargs: self.adapi.activate_all_off(cause='motion on'), lambda *args, **kwargs: self.adapi.activate_all_off(cause='motion on'),
entity_id=self.sensor_entity_id,
new='on', new='on',
oneshot=True, oneshot=True,
) )
self.logger.info( self.logger.info(
f'Waiting for sensor motion on [friendly_name]{self.sensor_entity.friendly_name}[/]' 'Waiting for sensor motion on '
f'[friendly_name]{self.sensor_entity.friendly_name}[/]'
) )
if self.sensor_state: if self.sensor_state:
self.logger.warning( self.logger.warning(
f'Sensor [friendly_name]{self.sensor_entity.friendly_name}[/] is already on', 'Sensor '
f'[friendly_name]{self.sensor_entity.friendly_name}[/] is already on',
) )
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.adapi.listen_state( self.sensor_entity.listen_state(
lambda *args, **kwargs: self.adapi.deactivate(cause='motion off'), lambda *args, **kwargs: self.adapi.deactivate(cause='motion off'),
entity_id=self.sensor_entity_id,
new='off', new='off',
duration=duration.total_seconds(), duration=duration.total_seconds(),
oneshot=True, oneshot=True,
) )
self.logger.debug( self.logger.debug(
f'Waiting for sensor [friendly_name]{self.sensor_entity.friendly_name}[/] to be clear for {duration}' 'Waiting for sensor '
f'[friendly_name]{self.sensor_entity.friendly_name}[/] '
f'to be clear for {duration}'
) )
if not self.sensor_state: if not self.sensor_state:

View File

@@ -3,14 +3,14 @@ import logging
import logging.config import logging.config
import traceback import traceback
from functools import wraps from functools import wraps
from typing import Any, Dict, List, Set from typing import Dict, List, Set
from appdaemon.entity import Entity from appdaemon.entity import Entity
from appdaemon.plugins.hass.hassapi import Hass from appdaemon.plugins.hass.hassapi import Hass
from astral.location import Location from astral.location import Location
from . import console from . import console
from .button import Button from .button import Button, VirtualButton
from .door import Door from .door import Door
from .model import ControllerStateConfig, RoomControllerConfig from .model import ControllerStateConfig, RoomControllerConfig
from .motion import MotionSensor from .motion import MotionSensor
@@ -56,8 +56,7 @@ class RoomController(Hass):
return self.get_entity(f'{self.name}.state', namespace='controller') return self.get_entity(f'{self.name}.state', namespace='controller')
def initialize(self): def initialize(self):
self.logger = console.load_rich_config(self.name) self.logger = console.load_rich_config(self.name, level=self.args.get('log_level', 'INFO'))
self.set_log_level('DEBUG')
self.refresh_state_times() self.refresh_state_times()
self.run_daily(callback=self.refresh_state_times, start='00:00:00') self.run_daily(callback=self.refresh_state_times, start='00:00:00')
@@ -82,13 +81,15 @@ 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) Button(self, button_name=button)
VirtualButton(self, button_name=button)
elif isinstance(button, list) and all(isinstance(b, str) for b in 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] for b in button:
Button(self, button_name=b)
VirtualButton(self, button_name=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.door = Door(self, entity_id=door) self.door = Door(self, entity_id=door)
if motion := self.args.get('motion'): if motion := self.args.get('motion'):
@@ -96,6 +97,7 @@ class RoomController(Hass):
self, sensor_entity_id=motion['sensor'], ref_entity_id=motion['ref_entity'] self, sensor_entity_id=motion['sensor'], ref_entity_id=motion['ref_entity']
) )
state: ControllerStateConfig
for state in sorted(self._room_config.states, key=lambda s: s.time, reverse=True): for state in sorted(self._room_config.states, key=lambda s: s.time, reverse=True):
if isinstance(state.time, datetime.datetime): if isinstance(state.time, datetime.datetime):
t = state.time.time() t = state.time.time()
@@ -151,7 +153,7 @@ class RoomController(Hass):
try: try:
self.run_at( self.run_at(
callback=lambda cb_args: self.set_controller_scene(cb_args['state']), callback=lambda **kwargs: self.set_controller_scene(kwargs['state']),
start=state.time.strftime('%H:%M:%S'), start=state.time.strftime('%H:%M:%S'),
state=state, state=state,
) )
@@ -185,8 +187,9 @@ class RoomController(Hass):
try: try:
attrs = self.state_entity.get_state('all')['attributes'] attrs = self.state_entity.get_state('all')['attributes']
state = ControllerStateConfig.model_validate(attrs) state = ControllerStateConfig.model_validate(attrs)
except Exception: except Exception as e:
state = ControllerStateConfig() state = ControllerStateConfig()
logger.exception(e)
finally: finally:
# self.log(f'Current state: {state.model_dump(exclude_none=True)}', level='DEBUG') # self.log(f'Current state: {state.model_dump(exclude_none=True)}', level='DEBUG')
return state return state
@@ -194,7 +197,7 @@ class RoomController(Hass):
def activate(self, **kwargs): def activate(self, **kwargs):
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):
# 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):
@@ -209,9 +212,7 @@ class RoomController(Hass):
"""Activate if any of the entities are on. Args and kwargs are passed directly to self.activate()""" """Activate if any of the entities are on. Args and kwargs are passed directly to self.activate()"""
self.call_service(f'{self.name}/activate_any_on', namespace='controller', **kwargs) self.call_service(f'{self.name}/activate_any_on', namespace='controller', **kwargs)
def _service_activate_any_on( def _service_activate_any_on(self, namespace: str, domain: str, service: str, **kwargs):
self, namespace: str, domain: str, service: str, kwargs: Dict[str, Any]
):
if self.any_on() and not self.manual_mode(): if self.any_on() and not self.manual_mode():
self.activate(**kwargs) self.activate(**kwargs)
@@ -219,25 +220,21 @@ class RoomController(Hass):
"""Activate if all of the entities are off. Args and kwargs are passed directly to self.activate()""" """Activate if all of the entities are off. Args and kwargs are passed directly to self.activate()"""
self.call_service(f'{self.name}/activate_all_off', namespace='controller', **kwargs) self.call_service(f'{self.name}/activate_all_off', namespace='controller', **kwargs)
def _service_activate_all_off( def _service_activate_all_off(self, namespace: str, domain: str, service: str, **kwargs):
self, namespace: str, domain: str, service: str, kwargs: Dict[str, Any]
):
if self.all_off() and not self.manual_mode(): if self.all_off() and not self.manual_mode():
self.activate(**kwargs) self.activate(**kwargs)
def deactivate(self, **kwargs): def deactivate(self, **kwargs):
self.call_service(f'{self.name}/deactivate', namespace='controller', **kwargs) self.call_service(f'{self.name}/deactivate', namespace='controller', **kwargs)
def _service_deactivate( def _service_deactivate(self, namespace: str, domain: str, service: str, **kwargs):
self, namespace: str, domain: str, service: str, kwargs: Dict[str, Any]
):
for e in self.app_entities: for e in self.app_entities:
self.turn_off(e) self.turn_off(e)
def toggle(self, **kwargs): def toggle(self, **kwargs):
self.call_service(f'{self.name}/toggle', namespace='controller', **kwargs) self.call_service(f'{self.name}/toggle', namespace='controller', **kwargs)
def _service_toggle(self, namespace: str, domain: str, service: str, kwargs: Dict[str, Any]): def _service_toggle(self, namespace: str, domain: str, service: str, **kwargs):
if self.any_on(): if self.any_on():
self.deactivate(**kwargs) self.deactivate(**kwargs)
else: else: