From 98d9ad0556917a4cfc81c9b8ed92b50e2eb657e7 Mon Sep 17 00:00:00 2001 From: John Lancaster <32917998+jsl12@users.noreply.github.com> Date: Fri, 21 Nov 2025 07:54:49 -0600 Subject: [PATCH] started stages logic --- apps/apps.yaml | 36 ++++++++++++++++++++++++++++++--- apps/motion.py | 55 ++++++++++++++++++++++++++++++++++++++++++++++++++ apps/stages.py | 36 +++++++++++++++++++++++++++++++++ 3 files changed, 124 insertions(+), 3 deletions(-) create mode 100644 apps/motion.py create mode 100644 apps/stages.py diff --git a/apps/apps.yaml b/apps/apps.yaml index 4c84034..1fb955d 100644 --- a/apps/apps.yaml +++ b/apps/apps.yaml @@ -1,3 +1,33 @@ -hello_world: - module: hello - class: HelloWorld +# hello_world: +# module: hello +# class: HelloWorld + + +bar_lights: + module: motion + class: StagedMotionLight + stages: + - start: '06:00 am' + scene: + light.bar: + state: on + color_temp_kelvin: 4500 + brightness: 25 + - start: '09:00 am' + scene: + light.bar: + state: on + color_temp_kelvin: 3500 + brightness: 100 + - start: '13:00' + scene: + light.bar: + state: on + color_temp_kelvin: 2500 + brightness: 150 + - start: 'sunset' + scene: + light.bar: + state: on + color_temp_kelvin: 2000 + brightness: 100 diff --git a/apps/motion.py b/apps/motion.py new file mode 100644 index 0000000..9b7b958 --- /dev/null +++ b/apps/motion.py @@ -0,0 +1,55 @@ +import json +from collections.abc import Generator +from datetime import datetime +from itertools import count, cycle, pairwise +from pathlib import Path + +from appdaemon.adapi import ADAPI +from pydantic import BaseModel, TypeAdapter + +from stages import Stage + +adapter = TypeAdapter(list[Stage]) + + +class StagedMotionLight(ADAPI): + def initialize(self): + self.set_log_level("DEBUG") + self._stages = adapter.validate_python(self.args["stages"]) + + self.log(f"Initialized Motion Sensor with {len(self._stages)} stages") + self.activate() + + 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 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}") + return stage + else: + raise ValueError + + def current_scene(self): + return self.current_stage().model_dump(mode="json")["scene"] + + def activate(self, **kwargs): + return self.call_service("scene/apply", entities=self.current_scene()) + + # @property + # def stages(self) -> GeneratorExit: + # yield from pairwise(self._stage_starts) diff --git a/apps/stages.py b/apps/stages.py new file mode 100644 index 0000000..29c6fc4 --- /dev/null +++ b/apps/stages.py @@ -0,0 +1,36 @@ +import functools +from datetime import datetime, time +from pathlib import Path +from typing import Annotated, Any + +from appdaemon.adapi import ADAPI +from pydantic import ( + BaseModel, + BeforeValidator, + Field, + PrivateAttr, + TypeAdapter, + field_serializer, + field_validator, +) +from rich import print as rprint + + +class EntityState(BaseModel): + state: bool = True + color_temp_kelvin: int + brightness: int + + @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 + _start: time = PrivateAttr() + scene: dict[str, EntityState]