commit 6c7b7696284025765f29bbe081738c153ab3c906 Author: docker Date: Sat Mar 18 14:36:08 2023 -0500 initial commit diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000..b23b1d0 --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,46 @@ +// For format details, see https://aka.ms/devcontainer.json. For config options, see the +// README at: https://github.com/devcontainers/templates/tree/main/src/docker-existing-dockerfile +{ + "name": "Appdaemon", + "build": { + // Sets the run context to one level up instead of the .devcontainer folder. + "context": "..", + // Update the 'dockerFile' property if you aren't using the standard 'Dockerfile' filename. + "dockerfile": "../Dockerfile.dev" + }, + + "mounts": [ + "source=/etc/localtime,target=/etc/localtime,type=bind,consistency=cached", + "source=/etc/timezone,target=/etc/timezone,type=bind,consistency=cached" + ], + + "workspaceMount": "source=${localWorkspaceFolder},target=/conf,type=bind", + "workspaceFolder": "/conf", + + // Features to add to the dev container. More info: https://containers.dev/features. + "features": { + "ghcr.io/devcontainers/features/github-cli:1": { + "version": "latest" + } + }, + + // Use 'forwardPorts' to make a list of ports inside the container available locally. + // "forwardPorts": [], + + // Uncomment the next line to run commands after the container is created. + // "postCreateCommand": "cat /etc/os-release", + + // Configure tool-specific properties. + "customizations": { + "vscode": { + // Add the IDs of extensions you want installed when the container is created. + "extensions": [ + "ms-python.python", + "ms-toolsai.jupyter" + ] + } + } + + // Uncomment to connect as an existing user other than the container default. More info: https://aka.ms/dev-containers-non-root. + // "remoteUser": "devcontainer" +} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5e48751 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +__pycache__ +*.pyc +*.png + +secrets.yaml \ No newline at end of file diff --git a/appdaemon.yaml b/appdaemon.yaml new file mode 100755 index 0000000..39f3721 --- /dev/null +++ b/appdaemon.yaml @@ -0,0 +1,22 @@ +appdaemon: + invalid_yaml_warnings: 0 + missing_app_warnings: 0 + latitude: 30.250968 + longitude: -97.748193 + elevation: 150 + time_zone: America/Chicago + plugins: + HASS: + type: hass + ha_url: http://192.168.1.245:8123 + token: !secret long_lived_token +http: + url: http://127.0.0.1:5050 +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' \ No newline at end of file diff --git a/apps/basic_motion.py b/apps/basic_motion.py new file mode 100755 index 0000000..339e450 --- /dev/null +++ b/apps/basic_motion.py @@ -0,0 +1,397 @@ +from copy import deepcopy +from datetime import time, timedelta +from typing import Dict, List, Tuple, Union + +from appdaemon.plugins.hass import hassapi as hass +from continuous import Continuous + + +class MotionLight(hass.Hass): + """Class for linking an light with a motion sensor. + + - Separate the turning on and turning off functions. + - Use the state change of the light to set up the event for changing to the other state + - `handle_on` + - `handle_off` + - When the light comes on, check if it's attributes match what they should, given the time. + """ + + def initialize(self): + self.state_change_handle = self.listen_state(self.state_change, self.entity) + self.sync_state() + self.schedule_transitions() + self.run_daily(callback=self.schedule_transitions, start='00:00:00') + + if (b := self.args.get('button')): + if not isinstance(b, list): + b = [b] + for button in b: + self.log(f'Setting up button: {button}') + self.listen_event(self.handle_button, event='deconz_event', id=button) + + if (door := self.args.get('door')): + self.log(f'Setting up door: {self.friendly_name(door)}') + self.listen_state( + callback=self.activate_all_off, + entity_id=door, + new='on' + ) + + self.log(f'All off: {self.all_off}') + + @property + def sensor(self) -> str: + return self.args['sensor'] + + @property + def entity(self) -> str: + return self.args['entity'] + + @property + def off_duration(self) -> timedelta: + """Determines the time that the motion sensor has to be clear before deactivating + + Priority: + - Value in scene definition + - Default value + - Normal - value in app definition + - Sleep - 0 + + """ + + if self.sleeping_active: + _, duration_str = self.sleep_scene() + else: + _, _, duration_str = self.current_setting() + + try: + hours, minutes, seconds = map(int, duration_str.split(':')) + return timedelta(hours=hours, minutes=minutes, seconds=seconds) + except Exception: + return timedelta() + + @property + def entity_state(self): + return self.get_state(self.entity) == 'on' + + @entity_state.setter + def entity_state(self, new): + if isinstance(new, str): + if new == 'on': + self.turn_on(self.entity) + elif new == 'off': + self.turn_on(self.entity) + else: + raise ValueError(f'Invalid value for entity state: {new}') + elif isinstance(new, bool): + if new: + self.turn_on(self.entity) + self.log(f'Turned on {self.friendly_name(self.entity)}') + else: + self.turn_off(self.entity) + self.log(f'Turned off {self.friendly_name(self.entity)}') + elif isinstance(new, dict): + if any(isinstance(val, dict) for val in new.values()): + # self.log(f'Setting scene with nested dict: {new}') + for entity, state in new.items(): + if state.pop('state', 'on') == 'on': + # self.log(f'Setting {entity} state with: {state}') + self.turn_on(entity_id=entity, **state) + else: + self.turn_off(entity) + else: + if new.pop('state', 'on') == 'on': + self.turn_on(self.entity, **new) + else: + self.turn_off(self.entity) + + else: + raise TypeError(f'Invalid type: {type(new)}: {new}') + + @property + def is_stateful(self): + return 'scene' in self.args and isinstance(self.args['scene'], (list, dict)) + + def settings(self) -> List[Tuple[time, Union[Dict, str], timedelta]]: + """Gets the settings for all the scenes based on time of day + + "Settings" refers to `tuple` groups that consist of + - Scene start time + - Dictionary of states or scene entity name + - Time for motion to be off + + Returns: + List[Tuple[time, Union[Dict, str], timedelta]]: Sorted list of settings + """ + assert self.is_stateful + return sorted( + (( + self.parse_time(s['time']), + s['scene'], + s.get('off_duration', self.args.get('off_duration', '00:00:00')) + ) + for s in self.args['scene']), + ) + + def current_setting(self) -> Tuple[time, Dict, timedelta]: + assert self.is_stateful + for dt, scene, off_duration in self.settings()[::-1]: + if dt <= self.time(): + # self.log(f'Active scene: {str(self.time())[:8]} {str(dt)[:8]}, {scene}, {off_duration}') + return dt, scene, off_duration + else: + self.log('Setting last scene') + return self.settings()[-1] + + def current_scene(self) -> Union[str, Dict]: + if self.sleeping_active: + return self.sleep_scene()[0] + else: + if self.is_stateful: + dt, scene, _ = self.current_setting() + self.log(f'Current scene: {str(dt)[:8]}, {scene}') + return scene + else: + return self.args['scene'] + + def sleep_scene(self) -> Tuple[Dict, timedelta]: + if (scene := self.args.get('sleep_scene')): + scene = deepcopy(scene) + if isinstance(scene, dict): + off_duration = scene.pop('off_duration', '00:00:00') + else: + off_duration = '00:00:00' + return scene, off_duration + else: + return None, None + + @property + def app_entities(self): + def gen(): + for settings in deepcopy(self.args['scene']): + # dt = self.parse_time(settings.pop('time')) + if (scene := settings.get('scene')): + if isinstance(scene, str): + yield from self.get_entity(scene).get_state('all')['attributes']['entity_id'] + else: + yield from scene.keys() + else: + yield self.args['entity'] + + return list(set(gen())) + + @property + def all_off(self) -> bool: + """"All off" is the logic opposite of "any on" + + Returns: + bool: Whether all the lights associated with the app are off + """ + return all(self.get_state(entity) != 'on' for entity in self.app_entities) + + @property + def any_on(self) -> bool: + """"Any on" is the logic opposite of "all off" + + Returns: + bool: Whether any of the lights associated with the app are on + """ + return any(self.get_state(entity) == 'on' for entity in self.app_entities) + + @property + def delay(self) -> timedelta: + try: + hours, minutes, seconds = map(int, self.args['delay'].split(':')) + return timedelta(hours=hours, minutes=minutes, seconds=seconds) + except Exception: + return timedelta() + + @property + def sleeping_active(self) -> bool: + if 'sleep' in self.args: + return self.get_state(self.args['sleep']) == 'on' + else: + return False + + @property + def sleep_bool(self) -> bool: + if (sleep_var := self.args.get('sleep')): + return self.get_state(sleep_var) == 'on' + + @sleep_bool.setter + def sleep_bool(self, val) -> bool: + if (sleep_var := self.args.get('sleep')): + if isinstance(val, str): + self.set_state(sleep_var, state=val) + elif isinstance(val, bool): + self.set_state(sleep_var, state='on' if val else 'off') + else: + raise ValueError('Sleep variable is undefined') + + def sync_state(self): + """Synchronizes the callbacks with the state of the light. + + Essentially mimics the `state_change` callback based on the current state of the light. + """ + if self.entity_state: + self.callback_light_on() + else: + self.callback_light_off() + + def listen_motion_on(self): + self.log( + f'Waiting for motion on {self.friendly_name(self.sensor)} to turn on {self.friendly_name(self.entity)}') + self.motion_on_handle = self.listen_state( + callback=self.activate, + entity_id=self.sensor, + new='on', + oneshot=True + ) + + def listen_motion_off(self, duration: timedelta): + self.log( + f'Waiting for motion to stop on {self.friendly_name(self.sensor)} for {duration} to turn off {self.friendly_name(self.entity)}') + self.motion_off_handle = self.listen_state( + callback=self.deactivate, + entity_id=self.sensor, + new='off', + duration=duration.total_seconds(), + oneshot=True + ) + + def cancel_motion(self, handle_name): + if hasattr(self, handle_name): + handle = getattr(self, handle_name) + try: + self.log(f'{handle_name}: {self.info_listen_state(handle)}') + except ValueError: + self.log(f'Error getting {handle_name} info') + else: + self.cancel_listen_state(handle) + self.log(f'Cancelled handle: {handle_name}') + finally: + delattr(self, handle_name) + else: + self.log(f'No attribute: {handle_name}') + + def state_change(self, entity=None, attribute=None, old=None, new=None, kwargs=None): + """Callback attached to the state change of the light. + """ + if new == 'on': + self.callback_light_on(entity, attribute, old, new, kwargs) + elif new == 'off': + self.callback_light_off(entity, attribute, old, new, kwargs) + else: + self.log(f'Uknown state: {new}') + + def callback_light_on(self, entity=None, attribute=None, old=None, new=None, kwargs=None): + """Called when the light turns on + """ + self.log('Light on callback') + self.cancel_motion('motion_on_handle') + self.listen_motion_off(self.off_duration) + # if self.is_stateful: + # self.activate() + + def callback_light_off(self, entity=None, attribute=None, old=None, new=None, kwargs=None): + """Called when the light turns on + """ + self.log('Light off callback') + self.cancel_motion('motion_off_handle') + self.listen_motion_on() + + def activate(self, *args, **kwargs): + self.log('Activating') + scene = self.current_scene() + + if isinstance(scene, str): + self.turn_on(scene) + self.log(f'Turned on scene: {scene}') + + elif isinstance(scene, dict): + for entity, settings in scene.items(): + if 'state' not in settings: + scene[entity]['state'] = 'on' + + daylights = [ + (entity, settings['state']) + for entity, settings in scene.items() + if isinstance(settings['state'], str) and settings['state'].startswith('daylight') + ] + + for entity, app_name in daylights: + # risky: + scene.pop(entity) + app: Continuous = self.get_app(app_name) + self.log(f'Adjusting with app...') + app.adjust() + + self.call_service('scene/apply', entities=scene, transition=0) + self.log(f'Applied scene: {scene}') + + elif scene is None: + self.log(f'No scene, ignoring...') + # Need to act as if the light had just turned off to reset the motion (and maybe other things?) + self.callback_light_off() + else: + self.log(f'ERROR: unknown scene: {scene}') + + def activate_all_off(self, *args, **kwargs): + if self.all_off: + self.activate() + else: + self.log(f'Skipped activating - everything is not off') + + def activate_any_on(self, kwargs): + if self.any_on(): + self.activate() + else: + self.log(f'Skipped activating - everything is not off') + + def deactivate(self, *args, **kwargs): + self.log('Deactivating') + for entity in self.app_entities: + self.turn_off(entity) + self.log(f'Turned off {entity}') + + def schedule_transitions(self, *args, **kwargs): + # times, scenes, offs = zip(*self.settings()) + for dt, scene, off_duration in self.settings(): + dt = str(dt)[:8] + self.log(f'Scheduling transition at: {dt}') + try: + self.run_at(callback=self.activate_any_on, start=dt) + except ValueError: + # happens when the callback time is in the past + pass + except Exception as e: + self.log(f'Failed with {type(e)}: {e}') + + def handle_button(self, event_name, data, kwargs): + # event 1002 is a single button press + if data['event'] == 1002: + self.log(f'{data["id"]} single click') + if self.entity_state: + self.deactivate() + else: + # use this instead of self.activate() to avoid it being called again by the state change event + # ^ no longer relevant with self.activate() commented out in self.callback_light_on() + # self.entity_state = True + self.activate() + + # event 1001 is a long press start + elif data['event'] == 1001: + self.log(f'{data["id"]} long press down') + if 'delay' in self.args and self.entity_state: + self.cancel_motion('motion_off_handle') + self.listen_motion_off(self.delay) + self.turn_on(self.entity, brightness_pct=100) + + # event 1004 is a double click + elif data['event'] == 1004: + self.log(f'{data["id"]} double click') + if 'sleep' in self.args: + self.sleep_bool = not self.sleep_bool + self.cancel_motion('motion_off_handle') + self.callback_light_on() + self.activate() diff --git a/apps/rooms.yaml b/apps/rooms.yaml new file mode 100755 index 0000000..9983cd1 --- /dev/null +++ b/apps/rooms.yaml @@ -0,0 +1,202 @@ +bathroom: + module: basic_motion + class: MotionLight + entity: light.bathroom + sensor: binary_sensor.aqarap1_motion + off_duration: '00:05:00' + button: button1 + delay: '00:20:00' + scene: + - time: '05:00:00' + scene: + light.bathroom: + brightness_pct: 40 + color_temp: 250 + - time: '12:00:00' + scene: + light.bathroom: + brightness_pct: 70 + color_temp: 300 + - time: sunset + scene: + light.bathroom: + brightness_pct: 50 + color_temp: 350 + - time: '23:00:00' + scene: + light.bathroom: + brightness_pct: 20 + color_temp: 350 + sleep: input_boolean.sleeping + sleep_scene: + off_duration: '00:02:00' + light.bathroom: + brightness_pct: 10 + color_temp: 250 + +closet: + module: basic_motion + class: MotionLight + entity: light.closet + sensor: binary_sensor.motion_closet + off_duration: '00:02:00' + scene: + - time: 'sunrise - 03:00:00' + scene: + light.closet: + brightness_pct: 10 + color_temp: 200 + - time: 'sunrise' + scene: + light.closet: + brightness_pct: 30 + color_temp: 200 + - time: '12:00:00' + scene: + light.closet: + brightness_pct: 70 + color_temp: 300 + - time: sunset + scene: + light.closet: + brightness_pct: 40 + color_temp: 400 + +bedroom: + module: basic_motion + class: MotionLight + entity: light.bedroom + sensor: binary_sensor.motion_bedroom + off_duration: '00:05:00' + button: + - bedroom_switch + - bedroom_switch_2 + scene: + - time: 'sunrise - 03:00:00' + scene: + light.bedroom: + state: on + color_temp: 550 + brightness_pct: 20 + light.overhead: + state: off + - time: '06:00:00' + scene: scene.wakeup + - time: '12:00:00' + scene: + light.bedroom: + state: on + color_temp: 325 + brightness_pct: 30 + light.overhead: + state: on + color_temp: 325 + brightness_pct: 70 + - time: 'sunset' + scene: scene.bedtime + sleep: input_boolean.sleeping + +living_room: + module: basic_motion + class: MotionLight + entity: light.living_room + sensor: binary_sensor.motion_living_room + off_duration: '00:30:00' + button: living_room_switch + door: binary_sensor.front_door + scene: + - time: sunrise + scene: + light.living_room: + state: on + color_temp: 200 + brightness_pct: 30 + light.corner_light: + state: on + color_temp: 200 + brightness_pct: 7 + - time: '09:00:00' + scene: + light.living_room: + state: on + color_temp: 250 + brightness_pct: 50 + light.corner_light: + state: on + color_temp: 250 + brightness_pct: 25 + - time: '12:00:00' + scene: + light.living_room: + state: on + color_temp: 300 + brightness_pct: 100 + light.corner_light: + state: on + color_temp: 450 + brightness_pct: 50 + - time: 'sunset' + off_duration: '01:00:00' + scene: + light.living_room: + state: on + color_temp: 350 + brightness_pct: 70 + light.corner_light: + state: on + color_temp: 650 + brightness_pct: 10 + sleep: input_boolean.sleeping + sleep_scene: + off_duration: '00:02:00' + light.living_room: + state: 'on' + color_name: 'red' + brightness_pct: 50 + +kitchen: + module: basic_motion + class: MotionLight + entity: light.kitchen + sensor: binary_sensor.motion_kitchen + off_duration: '00:10:00' + button: kitchen_switch + scene: + - time: sunrise + scene: + light.kitchen: + state: on + color_temp: 200 + brightness_pct: 10 + - time: '12:00:00' + scene: + light.kitchen: + state: on + color_temp: 300 + brightness_pct: 30 + - time: sunset + scene: + light.kitchen: + state: on + color_temp: 450 + brightness_pct: 40 + - time: '22:00:00' + off_duration: '00:02:00' + scene: + light.kitchen: + state: on + color_temp: 650 + brightness_pct: 10 + sleep: input_boolean.sleeping + sleep_scene: + light.kitchen: + state: on + brightness_pct: 1 + + +# patio: +# module: patio +# class: PatioLight +# light: light.patio +# linked: light.living_room +# door: binary_sensor.back_door diff --git a/docker_build.sh b/docker_build.sh new file mode 100755 index 0000000..ec4b1f3 --- /dev/null +++ b/docker_build.sh @@ -0,0 +1,7 @@ +#!/bin/bash + +PORT=${1:-8824} +SCRIPT_PATH=$(readlink -f ${BASH_SOURCE:-$0}) +SMARTHOME_PATH=$(dirname $(dirname $SCRIPT_PATH)) + +DOCKER_BUILDKIT=1 docker build -t appdaemon:custom $SMARTHOME_PATH/appdaemon diff --git a/docker_run.sh b/docker_run.sh new file mode 100755 index 0000000..3cbf70a --- /dev/null +++ b/docker_run.sh @@ -0,0 +1,8 @@ +#!/bin/bash + +docker run -it \ +-v /etc/localtime:/etc/localtime:ro \ +-v /etc/timezone:/etc/timezone:ro \ +-v /home/docker/appdaemon_conf:/conf \ +--name appdaemon \ +acockburn/appdaemon:dev diff --git a/requirements.txt b/requirements.txt new file mode 100755 index 0000000..b857243 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,5 @@ +--extra-index-url https://www.piwheels.org/simple +pvlib --only-binary=:all: +matplotlib --only-binary=:all: +rich --only-binary=:all: +jupyterlab \ No newline at end of file diff --git a/system_packages.txt b/system_packages.txt new file mode 100755 index 0000000..13ced87 --- /dev/null +++ b/system_packages.txt @@ -0,0 +1,10 @@ +cmake +git +py3-pandas +py3-scipy +py3-h5py +py3-matplotlib +openblas +openblas-dev +pkgconfig +py3-jupyter_core \ No newline at end of file