Compare commits

..

27 Commits

Author SHA1 Message Date
John Lancaster
3ae7c2b8e9 fewer lines 2025-12-04 23:56:18 -06:00
John Lancaster
01c6faa362 stage adjustments 2025-12-04 23:52:29 -06:00
John Lancaster
56ee0c3d3e adjusted for matter 2025-12-04 18:51:02 -06:00
John Lancaster
657d01a724 pruning 2025-12-02 22:08:27 -06:00
John Lancaster
e21eca4f42 started gone app 2025-12-02 22:08:15 -06:00
John Lancaster
61d5d99dee changed time format in logs 2025-12-02 22:07:20 -06:00
John Lancaster
29759692b2 added the govee stick to the bar lights stages 2025-12-02 22:07:07 -06:00
John Lancaster
fbd60ab6ac started moes pad driver and app 2025-12-02 10:50:30 -06:00
John Lancaster
2f52febf38 additional bar lights stage 2025-12-02 10:45:55 -06:00
John Lancaster
d8476e9fdf added explicit hermes dns 2025-12-02 10:45:38 -06:00
John Lancaster
43beb1e950 commented get_history call 2025-12-02 10:45:25 -06:00
John Lancaster
81b5d5afa5 app tweaks 2025-11-30 23:05:37 -06:00
John Lancaster
b33016089f added conditions and transtions 2025-11-30 23:05:21 -06:00
John Lancaster
25892e1de8 added transition config 2025-11-30 16:41:46 -06:00
John Lancaster
05bad81571 added server being off to some stages 2025-11-23 13:59:19 -06:00
John Lancaster
697b09a7a1 made activation at startup optional 2025-11-23 13:56:22 -06:00
John Lancaster
d90f2b28fa removed (currently broken) room_control submodule 2025-11-23 12:26:21 -06:00
John Lancaster
c1313409db configured log date fmt 2025-11-23 12:23:36 -06:00
John Lancaster
678cd75ae1 appdaemon.yaml completion 2025-11-23 12:20:05 -06:00
John Lancaster
b3d0993ee4 tweaked stage time 2025-11-23 12:19:49 -06:00
John Lancaster
8a889355aa added transition 2025-11-23 12:19:40 -06:00
John Lancaster
76027f6fc5 created stages dir 2025-11-21 21:54:54 -06:00
John Lancaster
d6941f8c7c optional kwargs 2025-11-21 18:33:14 -06:00
John Lancaster
a85a06b207 app name in event kwargs 2025-11-21 18:33:06 -06:00
John Lancaster
4e8f069123 first draft app 2025-11-21 18:32:56 -06:00
John Lancaster
852dbcfd8f stagedlight 2025-11-21 17:45:56 -06:00
John Lancaster
98d9ad0556 started stages logic 2025-11-21 07:54:49 -06:00
13 changed files with 455 additions and 14 deletions

3
.gitmodules vendored
View File

@@ -1,3 +0,0 @@
[submodule "apps/room_control"]
path = apps/room_control
url = https://gitea.john-stream.com/john/room_control

View File

@@ -1,8 +1,12 @@
appdaemon:
latitude: 0
longitude: 0
elevation: 30
uvloop: 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 +17,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'

View File

@@ -1,3 +1,11 @@
hello_world:
hello-world:
module: hello
class: HelloWorld
gone:
module: gone
class: Gone
entities:
- light.bar
- light.h6076
- light.h6076_2

7
apps/drivers/moes.yaml Normal file
View File

@@ -0,0 +1,7 @@
moes_driver:
module: moes_pad
class: MoesBridge
moes2:
module: moes_pad
class: MoesPad

73
apps/drivers/moes_pad.py Normal file
View File

@@ -0,0 +1,73 @@
from enum import Enum
from typing import Any
from appdaemon.adapi import ADAPI
from appdaemon.adbase import ADBase
class MoesButtons(int, Enum):
TOP_LEFT = 1
TOP_RIGHT = 2
BOTTOM_LEFT = 3
BOTTOM_RIGHT = 4
class MoesActions(str, Enum):
SINGLE_PRESS = 'single'
DOUBLE_PRESS = 'double'
LONG_PRESS = 'hold'
class MoesBridge(ADBase):
"""Class for an app that listens for state changes generated by 4 button Moes Pads and makes the events more
sensible with enums."""
adapi: ADAPI
def initialize(self):
self.adapi = self.get_ad_api()
self.log = self.adapi.log
self.event_handle = self.adapi.listen_state(self.handle_state_change)
def handle_state_change(self, entity: str, attribute: str, old: Any, new: Any, **kwargs: Any) -> None:
if new == '' or not new[0].isdigit():
return
domain, ent = entity.split('.', 1)
if domain == 'sensor' and ent.startswith('moes'):
button, action = new.split('_')
button = MoesButtons(int(button))
button_str = button.name.lower().replace('_', ' ')
action = MoesActions(action)
self.log(f'Moes Pad action detected: {action.value} on button {button_str}')
self.adapi.fire_event(
'moes_pad',
device=entity,
button=button,
action=action,
)
class MoesPad(ADBase):
def initialize(self):
self.adapi = self.get_ad_api()
self.log = self.adapi.log
self.event_handle = self.adapi.listen_event(self.handle_moes_event, 'moes_pad')
def handle_moes_event(self, event_name: str, data: dict[str, Any], **kwargs: Any) -> None:
button = MoesButtons(data.get('button'))
action = MoesActions(data.get('action'))
self.log(f'Received Moes Pad event: {action} on button {button}')
match action:
case MoesActions.SINGLE_PRESS:
self.handle_single_press(button)
def handle_single_press(self, button: MoesButtons) -> None:
match button:
case MoesButtons.TOP_LEFT:
self.adapi.call_service('light/toggle', entity_id='light.bedroom_sydney')
case MoesButtons.TOP_RIGHT:
self.adapi.call_service('light/toggle', entity_id='light.bedroom_john')
case MoesButtons.BOTTOM_LEFT:
self.adapi.call_service('light/toggle', entity_id='light.h6076_2')

27
apps/gone.py Normal file
View File

@@ -0,0 +1,27 @@
from typing import Any
from appdaemon.plugins.hass import Hass
class Gone(Hass):
def initialize(self):
people = self.get_state('person')
# self.log(json.dumps(people, indent=2))
self.log(list(people.keys()))
for person in self.get_state('person'):
self.listen_state(self.handle_state, entity_id=person, new='not_home')
self.log(f'No one home: {self.no_one_home()}')
def no_one_home(self) -> bool:
return all(state.get('state') != 'home' for state in self.get_state('person', copy=False).values())
def handle_state(self, entity: str, attribute: str, old: Any, new: Any, **kwargs: Any) -> None:
if self.no_one_home():
for ent in self.args['entities']:
try:
self.turn_off(ent)
except Exception:
self.log(f'Failed to turn off {ent}', level='ERROR')
continue

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')

94
apps/stages/bar.yaml Normal file
View File

@@ -0,0 +1,94 @@
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
light.living_room_stick:
state: "on"
brightness: 150
effect: sunset
- start: 'sunset + 1:00'
scene:
light.bar:
state: on
color_temp_kelvin: 2202
brightness: 75
light.server_lamp:
state: "on"
rgb_color: [255, 112, 86]
brightness: 175
light.living_room_stick:
state: "on"
brightness: 100
effect: sunset
- 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
light.living_room_stick:
state: "on"
brightness: 175
effect: sunset
- start: '11:30 pm'
scene:
light.bar:
state: on
color_temp_kelvin: 2202
brightness: 30
light.server_lamp:
state: on
rgb_color: [255, 112, 86]
brightness: 25
light.living_room_stick:
state: "on"
brightness: 75
effect: sunset

140
apps/stages/light.py Normal file
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, **_):
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.strftime("%I:%M %p")}', level='DEBUG')
self.run_at(self._check_transition, start=dt)
def _check_transition(self, **_):
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
apps/stages/stages.py Normal file
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']

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

View File

@@ -1,9 +1,12 @@
services:
appdaemon:
container_name: appdaemon
image: acockburn/appdaemon:dev
image: acockburn/appdaemon:dev
# image: acockburn/appdaemon:local-dev
restart: unless-stopped
tty: true
tty: true
dns:
- 192.168.1.150
volumes:
- /etc/localtime:/etc/localtime:ro
- /etc/timezone:/etc/timezone:ro