Compare commits

..
19 Commits
10 changed files with 313 additions and 14 deletions
-3
View File
@@ -1,3 +0,0 @@
[submodule "apps/room_control"]
path = apps/room_control
url = https://gitea.john-stream.com/john/room_control
+14 -3
View File
@@ -1,8 +1,13 @@
appdaemon:
latitude: 0
longitude: 0
elevation: 30
uvloop: True
use_dictionary_unpacking: True
import_method: expert
latitude: 30.250968
longitude: -97.748193
elevation: 150
time_zone: US/Central
exclude_dirs:
- room_control
plugins:
HASS:
type: hass
@@ -13,3 +18,9 @@ http:
admin:
api:
hadashboard:
logs:
main_log:
date_format: '%Y-%m-%d %I:%M:%S %p'
error_log:
date_format: '%Y-%m-%d %I:%M:%S %p'
+1 -1
View File
@@ -1,3 +1,3 @@
hello_world:
hello-world:
module: hello
class: HelloWorld
+6 -4
View File
@@ -1,7 +1,9 @@
from appdaemon.adapi import ADAPI
from appdaemon.plugins.hass import Hass
class HelloWorld(ADAPI):
class HelloWorld(Hass):
def initialize(self):
self.log("Hello from AppDaemon")
self.log("You are now ready to run Apps!")
self.log('Hello from AppDaemon')
self.log('You are now ready to run Apps!')
# self.get_history('ball1')
+66
View File
@@ -0,0 +1,66 @@
bar_lights:
module: light
class: StagedLight
activate-at-start: true
transition: 3
stages:
- start: '03:00 am'
scene:
light.bar:
state: on
color_temp_kelvin: 4500
brightness: 25
light.server_lamp:
state: off
- start: '07:00 am'
scene:
light.bar:
state: on
color_temp_kelvin: 3500
brightness: 75
light.server_lamp:
state: off
- start: '09:00 am'
scene:
light.bar:
state: on
color_temp_kelvin: 3500
brightness: 125
light.server_lamp:
state: off
- start: '01:00 pm'
scene:
light.bar:
state: on
color_temp_kelvin: 3000
brightness: 150
light.server_lamp:
state: off
- start: 'sunset'
scene:
light.bar:
state: on
color_temp_kelvin: 2202
brightness: 100
light.server_lamp:
state: on
rgb_color: [255, 112, 86]
brightness: 175
- start: '10:00 pm'
scene:
light.bar:
state: on
color_temp_kelvin: 2202
brightness: 75
light.server_lamp:
state: on
rgb_color: [255, 112, 86]
brightness: 75
- start: '11:30 pm'
scene:
light.bar:
state: on
color_temp_kelvin: 2202
brightness: 30
light.server_lamp:
state: off
+140
View File
@@ -0,0 +1,140 @@
from collections.abc import Generator
from datetime import datetime
from enum import Enum
from itertools import count
from itertools import cycle
from itertools import pairwise
from typing import Any
from appdaemon.plugins.hass import Hass
from pydantic import TypeAdapter
from stages import Stage
class StagedControlEvent(str, Enum):
"""Enum to define the different types of valid stage control events"""
ACTIVATE = 'activate'
DEACTIVATE = 'deactivate'
class StageCondition(str, Enum):
"""Enum to define the different types of conditions for an event"""
ANY_ON = 'any_on'
ALL_OFF = 'all_off'
class StagedLight(Hass):
def initialize(self):
self.set_log_level('DEBUG')
self._type_adapter = TypeAdapter(list[Stage])
self._stages = self._type_adapter.validate_python(self.args['stages'])
self.log(f'Initialized Motion Sensor with {len(self._stages)} stages')
self.listen_event(self.handle_event, 'stage_control', app=self.name)
if self.args.get('activate-at-start', False):
self.activate()
# self._check_transition()
self.schedule_transition_checks()
self.run_daily(self.schedule_transition_checks, start='00:00:00')
### Stages
def _stage_starts(self) -> Generator[datetime]:
for offset in count(start=-1):
for stage in self._stages:
dt = self.parse_datetime(stage.start, days_offset=offset, aware=True, today=True)
dt = dt.replace(microsecond=0)
yield dt
def start_pairs(self):
"""Yield from an infinite progression of start and end times for the stages."""
yield from pairwise(self._stage_starts())
def current_stage(self) -> Stage:
for stage, (t1, t2) in zip(cycle(self._stages), self.start_pairs()):
start, end = sorted([t1, t2])
if self.now_is_between(start, end):
self.log(f'Current stage start time: {stage.start}', level='DEBUG')
stage.assign_start(start)
return stage
else:
raise ValueError
def current_scene(self):
return self.current_stage().scene_json()
### Transitions
def schedule_transition_checks(self, **kwargs: Any):
now = self.get_now()
for stage in self._stages:
dt = self.parse_datetime(stage.start, aware=True, today=True)
if dt > now:
self.log(f'Scehduling transition at: {dt.isoformat()}', level='DEBUG')
self.run_at(self._check_transition, start=dt)
def _check_transition(self, **kwargs: Any):
self.log('Firing transition event', level='DEBUG')
self.fire_event(
'stage_control',
app=self.name,
condition=StageCondition.ANY_ON,
action=StagedControlEvent.ACTIVATE,
)
### Events
def handle_event(self, event_type: str, data: dict[str, Any], **kwargs: Any) -> None:
self.log(f'Event handler: {event_type}', level='DEBUG')
stage = self.current_stage()
scene = stage.scene_json()
match data:
case {'condition': StageCondition.ANY_ON}:
any_on = any(self.get_state(e) == 'on' for e in scene)
if not any_on:
self.log('Nothing is on, skipping', level='DEBUG')
return
case {'condition': StageCondition.ALL_OFF}:
all_off = all(self.get_state(e) == 'off' for e in scene)
if not all_off:
self.log('Everything is not off, skipping', level='DEBUG')
return
match data:
case {'action': StagedControlEvent.ACTIVATE}:
self.activate(stage)
case {'action': StagedControlEvent.DEACTIVATE}:
self.deactivate(stage)
case _:
self.log(str(data), level='DEBUG')
self.log(str(kwargs), level='DEBUG')
### Actions
def activate(self, stage: Stage | None = None, **kwargs: Any):
if stage is None:
stage = self.current_stage()
kwargs['entities'] = stage.scene_json()
else:
kwargs['entities'] = stage.scene_json()
if t := self.args.get('transition'):
kwargs['transition'] = t
start_time_str = stage.formatted_start('%I:%M %p')
self.log(f'Activating current stage: {start_time_str}')
return self.call_service('scene/apply', **kwargs)
def deactivate(self, stage: Stage | None = None, **kwargs):
stage = stage if stage is not None else self.current_stage()
start_time_str = stage.formatted_start('%I:%M %p')
self.log(f'Deactivating current stage: {start_time_str}')
for entity in stage.scene:
self.turn_off(entity)
+37
View File
@@ -0,0 +1,37 @@
from datetime import datetime
from typing import Any
from pydantic import BaseModel
from pydantic import PrivateAttr
from pydantic import field_serializer
class EntityState(BaseModel, extra='allow'):
state: bool = True
color_temp_kelvin: int | None = None
rgb_color: list[int] | None = None
brightness: int | None = None
@field_serializer('state')
def convert_state(self, val: Any):
if val:
return 'on'
else:
return 'off'
class Stage(BaseModel):
# start: Annotated[time, BeforeValidator(lambda v: parser(v).time())]
start: str
scene: dict[str, EntityState]
_start: datetime = PrivateAttr()
transition: int | None = None
def assign_start(self, dt: datetime):
self._start = dt
def formatted_start(self, fmt: str) -> str:
return self._start.strftime(fmt)
def scene_json(self):
return self.model_dump(mode='json')['scene']
+44
View File
@@ -0,0 +1,44 @@
upper_stairs:
module: light
class: StagedLight
stages:
- start: '04:00'
scene:
light.bathroom_stairs:
state: "on"
color_temp_kelvin: 4000
brightness: 30
light.spire:
state: "on"
color_temp_kelvin: 4000
brightness: 30
- start: '07:00'
scene:
light.bathroom_stairs:
state: "on"
color_temp_kelvin: 4000
brightness: 60
light.spire:
state: "on"
color_temp_kelvin: 4000
brightness: 60
- start: 'sunset'
scene:
light.bathroom_stairs:
state: "on"
color_temp_kelvin: 2202
brightness: 60
light.spire:
state: "on"
color_temp_kelvin: 2202
brightness: 60
- start: '11:00 pm'
scene:
light.bathroom_stairs:
state: "on"
color_temp_kelvin: 2202
brightness: 40
light.spire:
state: "on"
color_temp_kelvin: 2202
brightness: 40
+3
View File
@@ -2,8 +2,11 @@ services:
appdaemon:
container_name: appdaemon
image: acockburn/appdaemon:dev
# image: acockburn/appdaemon:local-dev
restart: unless-stopped
tty: true
dns:
- 192.168.1.150
volumes:
- /etc/localtime:/etc/localtime:ro
- /etc/timezone:/etc/timezone:ro