diff --git a/appdaemon/docker/Dockerfile b/appdaemon/docker/Dockerfile index 24d45ce..1bd411d 100644 --- a/appdaemon/docker/Dockerfile +++ b/appdaemon/docker/Dockerfile @@ -2,6 +2,9 @@ ARG VERSION FROM acockburn/appdaemon:${VERSION} RUN pip3 install \ + attrs==19.1.0 \ emp_mos_api==0.10 \ - sqlalchemy \ - mysqlclient + packaging==16.8 \ + python-Levenshtein==0.12.0 \ + voluptuous==0.11.5 \ + noolite==1.4.0 && apt-get update -y && apt-get install -y libusb-1.0-0-dev && apt-get clean diff --git a/appdaemon/settings/apps/const.py b/appdaemon/settings/apps/const.py new file mode 100644 index 0000000..ae94be0 --- /dev/null +++ b/appdaemon/settings/apps/const.py @@ -0,0 +1,57 @@ +"""Define various constants.""" +# Top level-related config keys: +CONF_ENTITY_IDS = "entity_ids" +CONF_PROPERTIES = "properties" + +# Entity-related config keys: +CONF_DEVICE_TRACKERS = "device_trackers" +CONF_ENTITY_ID = "entity_id" +CONF_ICON = "icon" + +# Name-related config keys: +CONF_FRIENDLY_NAME = "friendly_name" + +# State-related config keys: +CONF_STATE = "state" + +# Time-related config keys: +CONF_DELAY = "delay" +CONF_DURATION = "duration" +CONF_END_TIME = "end_time" +CONF_START_TIME = "start_time" +CONF_UPDATE_INTERVAL = "update_interval" + +# Constraint-related config keys: +CONF_CONSTRAIN_CLOUDY = "constrain_cloudy" +CONF_CONSTRAIN_SUN = "constrain_sun" + +# Notification-related config keys: +CONF_NOTIFIERS = "notifiers" +CONF_NOTIFICATION_INTERVAL = "notification_interval" +CONF_NOTIFICATION_TARGET = "notification_target" + +# Comparison-related config keys: +CONF_ABOVE = "above" +CONF_BELOW = "below" +CONF_EQUAL_TO = "equal_to" +COMPARITORS = (CONF_ABOVE, CONF_BELOW, CONF_EQUAL_TO) + +# Collections of valid inputs: +OPERATOR_ALL = "all" +OPERATOR_ANY = "any" +OPERATORS = (OPERATOR_ALL, OPERATOR_ANY) +TOGGLE_STATES = ("closed", "off", "on", "open") + +# Misc. config keys: +CONF_PEOPLE = "people" +CONF_TRIGGER_FIRST = "trigger_on_first_only" + +# Events: +EVENT_ALARM_CHANGE = "ALARM_CHANGE" +EVENT_MODE_CHANGE = "MODE_CHANGE" +EVENT_PRESENCE_CHANGE = "PRESENCE_CHANGE" +EVENT_PROXIMITY_CHANGE = "PROXIMITY_CHANGE" +EVENT_VACUUM_START = "VACUUM_START" + +# Misc.: +THRESHOLD_CLOUDY = 70.0 \ No newline at end of file diff --git a/appdaemon/settings/apps/core.py b/appdaemon/settings/apps/core.py new file mode 100644 index 0000000..a05cc14 --- /dev/null +++ b/appdaemon/settings/apps/core.py @@ -0,0 +1,325 @@ +"""Define a generic object which all apps/automations inherit from.""" +from typing import Callable, Dict, List, Union + +import voluptuous as vol +from appdaemon.plugins.hass.hassapi import Hass # pylint: disable=no-name-in-module + +from const import ( + CONF_ENTITY_IDS, + CONF_PROPERTIES, + EVENT_MODE_CHANGE, + OPERATOR_ALL, + OPERATORS, + THRESHOLD_CLOUDY, +) + +CONF_CLASS = "class" +CONF_MODULE = "module" +CONF_DEPENDENCIES = "dependencies" + +CONF_APP = "app" +CONF_CONSTRAINTS = "constraints" +CONF_MODE_ALTERATIONS = "mode_alterations" + +CONF_ENABLED_TOGGLE_ENTITY_ID = "enabled_toggle_entity_id" +CONF_INITIAL = "initial" +CONF_NAME = "name" + +CONF_OPERATOR = "operator" + +SENSOR_CLOUD_COVER = "sensor.dark_sky_cloud_coverage" + +APP_SCHEMA = vol.Schema( + { + vol.Required(CONF_MODULE): str, + vol.Required(CONF_CLASS): str, + vol.Optional(CONF_APP): str, + vol.Optional(CONF_CONSTRAINTS): vol.Schema( + { + vol.Optional(CONF_OPERATOR, default=OPERATOR_ALL): vol.In(OPERATORS), + vol.Required(CONF_CONSTRAINTS): dict, + } + ), + vol.Optional(CONF_ENABLED_TOGGLE_ENTITY_ID): str, + }, + extra=vol.ALLOW_EXTRA, +) + + +class Base(Hass): + """Define a base app/automation object.""" + + APP_SCHEMA = APP_SCHEMA + + def initialize(self) -> None: + """Initialize.""" + try: + self.APP_SCHEMA(self.args) + except vol.Invalid as err: + self.error("Invalid app schema: {0}".format(err), level="ERROR") + return + + # Define a holding place for HASS entity IDs: + self.entity_ids = self.args.get(CONF_ENTITY_IDS, {}) + + # Define a holding place for any scheduler handles that the app wants to keep + # track of: + self.handles = {} # type: Dict[str, Callable] + + # Define a holding place for key/value properties for this app: + self.properties = self.args.get(CONF_PROPERTIES, {}) + + # Define a holding place for any mode alterations for this app: + self.mode_alterations = self.args.get(CONF_MODE_ALTERATIONS, []) + + # Take every dependecy and create a reference to it: + for app in self.args.get(CONF_DEPENDENCIES, []): + if not getattr(self, app, None): + setattr(self, app, self.get_app(app)) + + # Define a reference to the "manager app" – for example, a trash- + # related app might carry a reference to TrashManager: + if self.args.get(CONF_APP): + self.app = getattr(self, self.args[CONF_APP]) + + # Set the entity ID of the input boolean that will control whether + # this app is enabled or not: + if self.args.get(CONF_ENABLED_TOGGLE_ENTITY_ID): + self._enabled_toggle_entity_id = self.args[CONF_ENABLED_TOGGLE_ENTITY_ID] + else: + self._enabled_toggle_entity_id = "input_boolean.{0}".format(self.name) + + # Register custom constraints: + self.register_constraint("constrain_anyone") + self.register_constraint("constrain_cloudy") + self.register_constraint("constrain_enabled") + self.register_constraint("constrain_everyone") + self.register_constraint("constrain_in_blackout") + self.register_constraint("constrain_noone") + self.register_constraint("constrain_sun") + + # Run any initial configuration: + if hasattr(self, "configure"): + self.configure() + + # Set up connections to the automation being disabled/enabled: + if self._enabled_entity_exists(): + # Listen and track mode changes so that the app can respond as needed: + self.mode_events = [] # type: List[str] + self.listen_event(self._on_mode_change, EVENT_MODE_CHANGE) + + # If the app has defined callbacks fror when the app is enabled or disabled, + # attach them to listeners. Note that we utilize `_on_*` here + # (leading underscore) – we do this so automations don't have to remember + # callback method signatures: + if hasattr(self, "on_disable"): + super().listen_state( + self._on_disable, self._enabled_toggle_entity_id, new="off" + ) + if hasattr(self, "on_enable"): + super().listen_state( + self._on_enable, self._enabled_toggle_entity_id, new="on" + ) + + @property + def enabled(self) -> bool: + """Return whether the app is enabled.""" + if not self._enabled_entity_exists(): + return True + return self.get_state(self._enabled_toggle_entity_id) == "on" + + def _attach_constraints( + self, method: Callable, callback: Callable, *args: list, **kwargs: dict + ) -> Union[str, list]: + """Attach the constraint mechanism to an AppDaemon listener.""" + if not self.args.get(CONF_CONSTRAINTS): + return method(callback, *args, **kwargs) + + constraints = self.args[CONF_CONSTRAINTS][CONF_CONSTRAINTS] + + if self.args[CONF_CONSTRAINTS].get(CONF_OPERATOR) == OPERATOR_ALL: + return method(callback, *args, **constraints, **kwargs) + + handles = [] # type: List[str] + for name, value in constraints.items(): + method(callback, *args, **{name: value}, **kwargs) + return handles + + def _constrain_presence(self, method: str, value: Union[str, None]) -> bool: + """Constrain presence in a generic fashion.""" + if not value: + return True + + return getattr(self.presence_manager, method)( + *[self.presence_manager.HomeStates[s] for s in value.split(",")] + ) + + def _enabled_entity_exists(self) -> bool: + """Return True if the enabled entity exists.""" + return self.entity_exists(self._enabled_toggle_entity_id) + + def _on_disable( + self, entity: Union[str, dict], attribute: str, old: str, new: str, kwargs: dict + ) -> None: + """Set a listener for when the automation is disabled.""" + self.on_disable() + + def _on_enable( + self, entity: Union[str, dict], attribute: str, old: str, new: str, kwargs: dict + ) -> None: + """Set a listener for when the automation is enabled.""" + self.on_enable() + + def _on_mode_change(self, event_name: str, data: dict, kwargs: dict) -> None: + """Compare mode changes to registered mode alterations.""" + mode = data["name"] + + if data["state"] == "on": + self.mode_events.append(mode) + elif mode in self.mode_events: + self.mode_events.remove(mode) + + try: + primary = max( + (m for m in self.mode_alterations if m["mode"] in self.mode_events), + key=lambda m: m["priority"], + ) + except ValueError: + try: + primary = next((m for m in self.mode_alterations if m["mode"] == mode)) + except StopIteration: + return + + if primary["action"] == "enable": + primary["action"] = "disable" + else: + primary["action"] = "enable" + + # If the primary mode alteration prescribes an action that matches the state the + # app is already in, return: + if (self.enabled and primary["action"] == "enable") or ( + not self.enabled and primary["action"] == "disable" + ): + return + + if primary["action"] == "enable": + self.enable() + else: + self.disable() + + def constrain_anyone(self, value: str) -> bool: + """Constrain execution to whether anyone is in a state.""" + return self._constrain_presence("anyone", value) + + def constrain_enabled(self, value: bool) -> bool: + """Constrain execution to whether anyone is in a state.""" + if value: + return self.enabled + return False + + def constrain_cloudy(self, value: bool) -> bool: + """Constrain execution based whether it's cloudy or not.""" + cloud_cover = float(self.get_state(SENSOR_CLOUD_COVER)) + if (value and cloud_cover >= THRESHOLD_CLOUDY) or ( + not value and cloud_cover < THRESHOLD_CLOUDY + ): + return True + return False + + def constrain_everyone(self, value: str) -> bool: + """Constrain execution to whether everyone is in a state.""" + return self._constrain_presence("everyone", value) + + def constrain_in_blackout(self, state: str) -> bool: + """Constrain execution based on blackout state.""" + if state is True: + return self.get_state("input_boolean.blackout_mode") == "on" + + return self.get_state("input_boolean.blackout_mode") == "off" + + def constrain_noone(self, value: str) -> bool: + """Constrain execution to whether no one is in a state.""" + return self._constrain_presence("noone", value) + + def constrain_sun(self, position: str) -> bool: + """Constrain execution to the location of the sun.""" + if (position == "up" and self.sun_up()) or ( + position == "down" and self.sun_down() + ): + return True + return False + + def disable(self) -> None: + """Disable the app.""" + if not self.entity_exists(self._enabled_toggle_entity_id): + return + + self.turn_off(self._enabled_toggle_entity_id) + + def enable(self) -> None: + """Enable the app.""" + if not self.entity_exists(self._enabled_toggle_entity_id): + return + + self.turn_on(self._enabled_toggle_entity_id) + + def listen_ios_event(self, callback: Callable, action: str) -> None: + """Register a callback for an iOS event.""" + self.listen_event( + callback, + "ios.notification_action_fired", + actionName=action, + constrain_enabled=True, + ) + + def listen_event(self, callback, event=None, auto_constraints=False, **kwargs): + """Wrap AppDaemon's `listen_event` with the constraint mechanism.""" + if not auto_constraints: + return super().listen_event(callback, event, **kwargs) + + return self._attach_constraints(super().listen_event, callback, event, **kwargs) + + def listen_state(self, callback, entity=None, auto_constraints=False, **kwargs): + """Wrap AppDaemon's `listen_state` with the constraint mechanism.""" + if not auto_constraints: + return super().listen_state(callback, entity, **kwargs) + + return self._attach_constraints( + super().listen_state, callback, entity, **kwargs + ) + + def run_daily(self, callback, start, auto_constraints=False, **kwargs): + """Wrap AppDaemon's `run_daily` with the constraint mechanism.""" + if not auto_constraints: + return super().run_daily(callback, start, **kwargs) + + return self._attach_constraints(super().run_daily, callback, start, **kwargs) + + def run_at_sunrise(self, callback, *args, auto_constraints=False, **kwargs): + """Wrap AppDaemon's `run_at_sunrise` with the constraint mechanism.""" + if not auto_constraints: + return super().run_at_sunrise(callback, **kwargs) + + return self._attach_constraints(super().run_at_sunrise, callback, **kwargs) + + def run_at_sunset( + self, + callback: Callable[..., None], + *args: list, + auto_constraints=False, + **kwargs: dict + ): + """Wrap AppDaemon's `run_at_sunset` with the constraint mechanism.""" + if not auto_constraints: + return super().run_at_sunset(callback, **kwargs) + + return self._attach_constraints(super().run_at_sunset, callback, **kwargs) + + def run_every(self, callback, start, interval, auto_constraints=False, **kwargs): + """Wrap AppDaemon's `run_every` with the constraint mechanism.""" + if not auto_constraints: + return super().run_every(callback, start, interval, **kwargs) + + return self._attach_constraints( + super().run_every, callback, start, interval, **kwargs + ) \ No newline at end of file diff --git a/appdaemon/settings/apps/light/salt_lamp.py b/appdaemon/settings/apps/light/salt_lamp.py new file mode 100644 index 0000000..6b58449 --- /dev/null +++ b/appdaemon/settings/apps/light/salt_lamp.py @@ -0,0 +1,38 @@ +import appdaemon.plugins.hass.hassapi as hass +import noolite + +class Noolite(hass.Hass): + noolite = noolite.NooLite() + brightness = 25 + force = False + channel = 0 + + def initialize(self): + self.run_at_sunrise(self.sunrise_cb) + self.run_at_sunset(self.sunset_cb) + self.listen_state(self.light_change, "group.mainroom_light") + self.listen_event(self.away_mode, "away_mode") + + def away_mode(self, event_id, event_args, kwargs): + self.noolite_off() + + def light_change(self, entity, attribute, old, new, kwargs): + sun_down = self.sun_down() + if new == "off" and (sun_down or self.force): + self.noolite_on(self.brightness) + else: + self.noolite_off() + + def noolite_off(self): + self.log("Turning off salt lamp.") + self.noolite.off(self.channel) + + def noolite_on(self, brightness=120): + self.log("Turning on salt lamp for brightness {}.".format(brightness)) + self.noolite.set(self.channel, brightness) + + def sunset_cb(self, kwargs): + self.noolite_on(self.brightness) + + def sunrise_cb(self, kwargs): + self.noolite_off() diff --git a/appdaemon/settings/apps/light/salt_lamp.yaml b/appdaemon/settings/apps/light/salt_lamp.yaml new file mode 100644 index 0000000..e110a5c --- /dev/null +++ b/appdaemon/settings/apps/light/salt_lamp.yaml @@ -0,0 +1,3 @@ +salt_lamp: + module: salt_lamp + class: Noolite \ No newline at end of file diff --git a/appdaemon/settings/apps/notification/__init__.py b/appdaemon/settings/apps/notification/__init__.py new file mode 100644 index 0000000..636d2cc --- /dev/null +++ b/appdaemon/settings/apps/notification/__init__.py @@ -0,0 +1,129 @@ +"""Define the ability to send single and repeating notifications.""" +# pylint: disable=too-few-public-methods +from datetime import datetime +from typing import Callable, List, Optional, Union +from uuid import uuid4 + +import attr + +from core import Base # pylint: disable=no-name-in-module +from notification.target import Target, get_targets_from_string + +CONF_NOTIFICATION_HANDLES = "notification_handles" + + +@attr.s(slots=True, auto_attribs=True) +class Notification: + """Define a base notification object.""" + + # App reference: + _app: Base + + # Raw targets, message, and optional title: + targets: Union[str, List[str]] + message: str + title: Optional[str] = None + + # Scheduling properties: + urgent: bool = False + repeat: bool = False + when: Optional[datetime] = None + interval: Optional[int] = None + + iterations: Optional[int] = None + _iteration_count: int = 0 + + # Message data + data: Optional[dict] = None + + # "Auto-generated" properties: + id: str = attr.Factory(lambda: uuid4().hex) + + def __attrs_post_init__(self): + """Perform some post-__init__ initialization.""" + # Initialize a shared data space for handlers: + if CONF_NOTIFICATION_HANDLES not in self._app.global_vars: + self._app.global_vars[CONF_NOTIFICATION_HANDLES] = {} + + # Give every notification a parameter that will allow it to be + # threaded on iOS; this shouldn't hurt any non-iOS notifier: + if not self.data: + self.data = {} + self.data.setdefault("push", {"thread-id": self.id}) + + def _cancel(self) -> None: + """Cancel the notification.""" + if self.id not in self._app.global_vars[CONF_NOTIFICATION_HANDLES]: + return + + handle = self._app.global_vars[CONF_NOTIFICATION_HANDLES].pop(self.id) + if handle: + self._app.cancel_timer(handle) + + def _on_send(self, kwargs: dict) -> None: + """Send a single (immediate or scheduled) notification.""" + # If this is a repeating notification, it's already been sent once, and + # we've exceeded our iterations, cancel right away: + if self.iterations and self._iteration_count == self.iterations: + self._cancel() + return + + if isinstance(self.targets, str): + self.targets = [self.targets] + + targets = [] # type: List[Target] + for target_str in self.targets: + targets += get_targets_from_string(self._app, target_str) + + for target in targets: + target.payload["data"] = self.data + if self.title: + target.payload["title"] = self.title + if target.payload.get("message"): + target.payload["message"] += self.message + else: + target.payload["message"] = self.message + + self._app.call_service(target.service_call, **target.payload) + + if self.iterations: + self._iteration_count += 1 + + def send(self) -> Callable: + """Send the notification.""" + handle = None + if self.when and self.interval: + handle = self._app.run_every(self._on_send, self.when, self.interval) + elif self.when: + handle = self._app.run_at(self._on_send, self.when) + else: + self._on_send({}) + + self._app.global_vars[CONF_NOTIFICATION_HANDLES][self.id] = handle + return self._cancel + + +def send_notification( + app: Base, + targets: Union[str, List[str]], + message: str, + title: str = None, + urgent: bool = False, + when: datetime = None, + interval: int = None, + iterations: int = None, + data: dict = None, +) -> Callable: + """Send/schedule a notification and return a method to cancel it.""" + notification = Notification( + app=app, + targets=targets, + message=message, + title=title, + urgent=urgent, + when=when, + interval=interval, + iterations=iterations, + data=data, + ) + return notification.send() \ No newline at end of file diff --git a/appdaemon/settings/apps/notification/target.py b/appdaemon/settings/apps/notification/target.py new file mode 100644 index 0000000..4427acc --- /dev/null +++ b/appdaemon/settings/apps/notification/target.py @@ -0,0 +1,128 @@ +"""Define various notification target types.""" +# pylint: disable=too-few-public-methods +from typing import Dict, List + +from const import CONF_PEOPLE +from core import Base # pylint: disable=no-name-in-module + + +class Target: + """Define a target for generic notifications.""" + + def __init__(self, notify_service: str) -> None: + """Initialize.""" + self.payload = {} # type: Dict[str, str] + self.service_call = "notify/{0}".format(notify_service) + + +class SlackTarget(Target): + """Define a target for Slack notifications.""" + + def __init__(self, channel: str = None, mention: str = None) -> None: + """Initialize.""" + super().__init__("slack") + + if channel: + self.payload["target"] = "#{0}".format(channel) + if mention: + self.payload["message"] = "{0}: ".format(mention) + + +class TargetFactory: + """Define an abstract factory.""" + + def __init__(self, app: Base, target: str) -> None: + """Initialization.""" + self._app = app + self._target = target + + def build(self) -> List: + """Build notification target objects from a string representation.""" + raise NotImplementedError() + + +class NotifierFactory(TargetFactory): + """Define a factory to build generic notification targets.""" + + def build(self) -> List[Target]: + """Build notification target objects from a string representation.""" + return [Target(self._target)] + + +class PersonFactory(TargetFactory): + """Define a factory to build notification targets specific to a person.""" + + def build(self) -> List[Target]: + """Build notification target objects from a string representation.""" + name = self._target.split("person:")[1] + + try: + person = next( + (p for p in self._app.global_vars[CONF_PEOPLE] if p.first_name == name) + ) + except StopIteration: + self._app.error("Unknown person: {0}".format(self._target)) + return [] + + targets = [] # type: List[Target] + for target in person.notifiers: + targets += get_targets_from_string(self._app, target) + + return targets + + +class PresenceFactory(TargetFactory): + """Define a factory to build notification targets specific to presence.""" + + def build(self) -> List[Target]: + """Build notification target objects from a string representation.""" + presence = self._target.split(":")[1] + presence_manager = self._app.get_app("presence_manager") + + try: + presence_method = getattr(presence_manager, "whos_{0}".format(presence)) + except AttributeError: + self._app.error("Unknown presence target: {0}".format(presence)) + return [] + + targets = [] # type: List[Target] + for person in presence_method(): + for target in person.notifiers: + targets += get_targets_from_string(self._app, target) + + return targets + + +class SlackFactory(TargetFactory): + """Define a factory to build Slack notification targets.""" + + def build(self) -> List[SlackTarget]: + """Build notification target objects from a string representation.""" + data = self._target.split(":") + + if len(data) == 1: + return [SlackTarget(None, None)] + + splits = data[1].split("/") + + if len(splits) == 2: + return [SlackTarget(splits[0], splits[1])] + + if "@" in splits[0]: + return [SlackTarget(None, splits[0])] + + return [SlackTarget(splits[0], None)] + + +def get_targets_from_string(app: Base, target: str) -> List[Target]: + """Return the appropriate factory for the passed target.""" + if target.startswith("person:"): + factory = PersonFactory(app, target) + elif target.startswith("presence:"): + factory = PresenceFactory(app, target) # type: ignore + elif target.startswith("slack:"): + factory = SlackFactory(app, target) # type: ignore + else: + factory = NotifierFactory(app, target) # type: ignore + + return factory.build() \ No newline at end of file diff --git a/appdaemon/settings/apps/scenes/alice_scenes.py b/appdaemon/settings/apps/scenes/alice_scenes.py index 5330943..d18c53f 100644 --- a/appdaemon/settings/apps/scenes/alice_scenes.py +++ b/appdaemon/settings/apps/scenes/alice_scenes.py @@ -36,6 +36,12 @@ class AliceScenes(hass.Hass): self.turn_off_light() if (entity_name == "night_mode"): self.night_mode() + if (entity_name == "turn_on_coffeemachine"): + self.turn_on_coffeemachine() + if (entity_name == "turn_on_coffeemachine"): + self.turn_on_coffeemachine() + if (entity_name == "make_coffee"): + self.make_coffee() def terminate(self): if self.listen_event_handle_list != None: @@ -48,4 +54,17 @@ class AliceScenes(hass.Hass): def night_mode(self): self.turn_off('switch.plug_158d0001104a0c') #tv self.turn_off('group.all_lights') - self.call_service("cover/close_cover", entity_id = 'cover.curtain_158d0002b0c46a') \ No newline at end of file + self.call_service("cover/close_cover", entity_id = 'cover.curtain_158d0002b0c46a') + + def turn_on_coffeemachine(self): + self.log('Turn on coffeemachine') + self.call_service("mqtt/publish", topic = 'home/coffee/coffee/power/set', payload = 'on') + + def make_coffee(self): + self.log('Make coffee') + self.call_service("mqtt/publish", topic = 'home/coffee/coffee/power/set', payload = 'on') + self.timer = self.run_in(self.run_in_make_coffee, 30) + self.log('Waiting for power on.') + + def run_in_make_coffee(self, args): + self.call_service("mqtt/publish", topic = 'home/coffee/coffee/coffee/set', payload = 'on') diff --git a/appdaemon/settings/apps/security/curtainsecurity.py b/appdaemon/settings/apps/security/curtainsecurity.py index 1454079..2f395fb 100644 --- a/appdaemon/settings/apps/security/curtainsecurity.py +++ b/appdaemon/settings/apps/security/curtainsecurity.py @@ -13,14 +13,16 @@ import globals # Initial Version class CurtainSecurity(hass.Hass): + listen_event_handle_list = [] def initialize(self): if not globals.check_args(self, ['curtain', 'door']): return - self.listen_event(self.curtain_changed, event='call_service', entity_id = self.args['curtain']) - # self.listen_event_handle_list = [] - # locks = globals.get_group_entities(self, "group.all_locks") - # for lock in locks: - # self.listen_event_handle_list.append(self.listen_state(self.lock_state_changed, lock)) + self.listen_event_handle_list.append(self.listen_event(self.curtain_changed, event='call_service', entity_id = self.args['curtain'])) + + def terminate(self): + if self.listen_event_handle_list != None: + for listen_event_handle in self.listen_event_handle_list: + self.cancel_listen_event(listen_event_handle) def curtain_changed(self, event_name, data, kwargs): if 'constraint' in self.args and not self.constrain_input_boolean(self.args['constraint']): diff --git a/appdaemon/settings/apps/security/security_sensors.py b/appdaemon/settings/apps/security/security_sensors.py index 70949a2..c0c3c8d 100644 --- a/appdaemon/settings/apps/security/security_sensors.py +++ b/appdaemon/settings/apps/security/security_sensors.py @@ -36,6 +36,14 @@ class SecuritySensorsReport(hass.Hass): alarm_panel_state = self.get_state(self.args['ha_panel']) if alarm_panel_state != "armed_away": return + + vacuum_state = "docked" + if "vacuum" in self.args: + vacuum_state = self.get_state(self.args["vacuum"]) + #Если работает пылесос и сработал датчик движения + if vacuum_state == 'cleaning' and 'binary_sensor.motion' in entity: + self.log('Motion sensor is triggered, but vacuum is cleaning.') + return self.alarm_triggered(entity, old, new) def alarm_triggered(self, entity, old, new): diff --git a/appdaemon/settings/apps/security/security_sensors.yaml b/appdaemon/settings/apps/security/security_sensors.yaml index cf02473..89f2c0b 100644 --- a/appdaemon/settings/apps/security/security_sensors.yaml +++ b/appdaemon/settings/apps/security/security_sensors.yaml @@ -4,6 +4,7 @@ security_sensors: sensor: group.security_sensors constraint: input_boolean.security_sensors_report ha_panel: alarm_control_panel.ha_alarm + vacuum: vacuum.xiaomi_vacuum_cleaner notify: telegram global_dependencies: - globals \ No newline at end of file diff --git a/appdaemon/settings/apps/sensors/mosenergosbyt.py b/appdaemon/settings/apps/sensors/mosenergosbyt.py index b6d1652..0692dac 100644 --- a/appdaemon/settings/apps/sensors/mosenergosbyt.py +++ b/appdaemon/settings/apps/sensors/mosenergosbyt.py @@ -58,8 +58,8 @@ class MosEnergoSbytUpdater(Automation): self.set_state(entity_id, state = balance, attributes = attributes) class MosEnergoSbytCounter(Base): - _version = 11 - _device = "ios/12.1.2/2.14.14(221)" + _version = 13 + _device = "android/11.2.6/2.9.15(105)" _session = None _profile = None @@ -132,6 +132,8 @@ class MosEnergoSbytCounter(Base): url = "https://lkkbyt.mosenergosbyt.ru/gate/do?api_version={}&id_device={}&phone={}&ls={}&process=login&pw_abn={}&type=1".format(self._version, self._device, phone, ls, pass_md5) response = requests.get(url).text session_response = self._parse_response(response) + if ('ID_SESSION' not in session_response): + return session = session_response['ID_SESSION'] return session @@ -144,9 +146,19 @@ class MosEnergoSbytCounter(Base): headers.append(node.text) for node in et.findall('./b/p'): values.append(node.text) + result = {} + if len(headers) == 0: + return result + for i in range(0, len(headers)): + if i>=len(headers) or i>=len(values): + continue header = headers[i] value = values[i] result[header] = value + + if 'NM_RESULT' in result and 'Вышла новая версия приложения' in result['NM_RESULT']: + self.error('[!] new version is available. Please update: {}.'.format(result)) + return result diff --git a/appdaemon/settings/apps/sensors/mosenergosbyt.yaml b/appdaemon/settings/apps/sensors/mosenergosbyt.yaml index 63f5483..d1d11b4 100644 --- a/appdaemon/settings/apps/sensors/mosenergosbyt.yaml +++ b/appdaemon/settings/apps/sensors/mosenergosbyt.yaml @@ -1,47 +1,47 @@ -mosenergo_counter: - module: mosenergosbyt - class: MosEnergoSbytCounter - properties: - login: !secret mosenergo_main_login - pwd: !secret mosenergo_main_pwd +# mosenergo_counter: +# module: mosenergosbyt +# class: MosEnergoSbytCounter +# properties: +# login: !secret mosenergo_main_login +# pwd: !secret mosenergo_main_pwd -mosenergo_counter_updater: - module: mosenergosbyt - class: MosEnergoSbytUpdater - entity_ids: - mosenergosbyt_total: sensor.power_mosenergosbyt_total - mosenergosbyt_balance: sensor.power_mosenergosbyt_balance - app: mosenergo_counter - dependencies: - - mosenergo_counter - global_dependencies: - - globals +# mosenergo_counter_updater: +# module: mosenergosbyt +# class: MosEnergoSbytUpdater +# entity_ids: +# mosenergosbyt_total: sensor.power_mosenergosbyt_total +# mosenergosbyt_balance: sensor.power_mosenergosbyt_balance +# app: mosenergo_counter +# dependencies: +# - mosenergo_counter +# global_dependencies: +# - globals -mosenergo_counter_sender: - module: mosenergosbyt - class: MosEnergoSbytSender - entity_ids: - home_power_total: sensor.power_main_total - app: mosenergo_counter - dependencies: - - mosenergo_counter - global_dependencies: - - globals +# mosenergo_counter_sender: +# module: mosenergosbyt +# class: MosEnergoSbytSender +# entity_ids: +# home_power_total: sensor.power_main_total +# app: mosenergo_counter +# dependencies: +# - mosenergo_counter +# global_dependencies: +# - globals -mosenergo_alt: - module: mosenergosbyt - class: MosEnergoSbytCounter - properties: - login: !secret mosenergo_alt_login - pwd: !secret mosenergo_alt_pwd +# mosenergo_alt: +# module: mosenergosbyt +# class: MosEnergoSbytCounter +# properties: +# login: !secret mosenergo_alt_login +# pwd: !secret mosenergo_alt_pwd -mosenergo_alt_updater: - module: mosenergosbyt - class: MosEnergoSbytUpdater - app: mosenergo_alt - dependencies: - - automation - - mosenergo_alt - global_dependencies: - - globals \ No newline at end of file +# mosenergo_alt_updater: +# module: mosenergosbyt +# class: MosEnergoSbytUpdater +# app: mosenergo_alt +# dependencies: +# - automation +# - mosenergo_alt +# global_dependencies: +# - globals \ No newline at end of file diff --git a/appdaemon/settings/apps/sensors/mosru.py b/appdaemon/settings/apps/sensors/mosru.py index 20c03b1..32c1e8a 100644 --- a/appdaemon/settings/apps/sensors/mosru.py +++ b/appdaemon/settings/apps/sensors/mosru.py @@ -12,6 +12,8 @@ class WaterCounterSender(Automation): self.run_daily(self.send_mosru_counters, dt.time(12, 00, 0)) def send_mosru_counters(self, kwargs): + if 'constraint' in self.args and not self.constrain_input_boolean(self.args['constraint']): + return self.log('Starting sending counters value to mos.ru') new_values = [] self.app.update_watercounters() @@ -47,11 +49,38 @@ class WaterCounterSender(Automation): self.log('error sending: {}'.format(err)) +class PowerCounterSender(Automation): + timezone = '+03:00' + def initialize(self): + super().initialize() + if 'power_total' not in self.entity_ids: + self.log('Please specify power_total for PowerCounterSender.') + return + self.run_daily(self.send_mosru_counters, dt.time(14, 00, 0)) + # self.send_mosru_counters("") + + def send_mosru_counters(self, kwargs): + if 'constraint' in self.args and not self.constrain_input_boolean(self.args['constraint']): + return + today = datetime.today() + if today.day < 15 or today.day > 26: + self.log("We can send power values between days 15 and 26.") + return + power_total = round(float(self.get_state(self.entity_ids['power_total']))) + period = datetime.now().strftime('%Y-%m-%d') + self.log('Starting sending power counters value ({}) to mosru.'.format(power_total)) + new_values = [{'counter_id': 'T1', 'indication': power_total, 'period': period}] + try: + result = self.app.send_electrocounters(new_values) + self.log(result) + except EmpServerException as err: + self.log('error sending: {}'.format(err)) + class WaterCounterSensor(Automation): timezone = '+03:00' def initialize(self): super().initialize() - self.timer = self.run_every(self.update_sensors, self.datetime(), 1*60*60) + self.timer = self.run_every(self.update_sensors, self.datetime()+timedelta(seconds=10), 1*60*60) def update_sensors(self, args) -> None: self.log('Updating water counters') @@ -102,6 +131,33 @@ class WaterCounterSensor(Automation): self.set_state(entity_id, state = indication_max, attributes = attributes) +class PowerCounterSensor(Automation): + timezone = '+03:00' + def initialize(self): + super().initialize() + self.timer = self.run_every(self.update_sensors, self.datetime()+timedelta(seconds=30), 1*60*60) + + def update_sensors(self, args) -> None: + self.log('Updating power counters') + self.app.update_power() + if 'power_total' in self.entity_ids: + power_total = self.app.power_total + self.create_power_entity(self.entity_ids['power_total'], power_total, 'kWh', 'Счетчик') + if 'power_balance' in self.entity_ids: + power_balance = self.app.power_balance + self.create_power_entity(self.entity_ids['power_balance'], power_balance, '₽', 'Баланс') + + def create_power_entity(self, entity_id, value, unit, description): + icon = "mdi:av-timer" + attributes = {} + attributes['unit_of_measurement'] = unit + attributes['icon'] = icon + attributes['friendly_name'] = "{}(mos.ru)".format(description) + attributes['description'] = 'Created and updated from appdaemon ({})'.format(__name__) + + self.set_state(entity_id, state = value, attributes = attributes) + + class EpdBalanceSensor(Automation): def initialize(self): super().initialize() @@ -184,6 +240,11 @@ class MosruPower(MosruBase): self._check_login() self._power_counters = self._api.get_electrocounters(self._flat_id) + def send_electrocounters(self, new_values) -> None: + self._check_login() + self._api.send_electrocounters(self._flat_id, new_values) + + class MosruWater(MosruBase): _water_counters = None diff --git a/appdaemon/settings/apps/sensors/mosru.yaml b/appdaemon/settings/apps/sensors/mosru.yaml index db49d9a..9cfc8fe 100644 --- a/appdaemon/settings/apps/sensors/mosru.yaml +++ b/appdaemon/settings/apps/sensors/mosru.yaml @@ -25,6 +25,19 @@ epd_sensor: global_dependencies: - globals + +power_sensor: + class: PowerCounterSensor + module: mosru + entity_ids: + power_balance: sensor.power_mosenergosbyt_balance + power_total: sensor.power_mosenergosbyt_total + app: mosru_client + dependencies: + - mosru_client + global_dependencies: + - globals + water_sensor: class: WaterCounterSensor module: mosru @@ -49,3 +62,14 @@ water_sender: global_dependencies: - globals +power_sender: + class: PowerCounterSender + module: mosru + entity_ids: + power_total: sensor.power_main_total + app: mosru_client + dependencies: + - mosru_client + global_dependencies: + - globals + diff --git a/appdaemon/settings/apps/test.py b/appdaemon/settings/apps/test.py new file mode 100644 index 0000000..207a745 --- /dev/null +++ b/appdaemon/settings/apps/test.py @@ -0,0 +1,26 @@ +import appdaemon.plugins.hass.hassapi as hass +from notification import send_notification +import datetime +# from automation import Automation # type: ignore + +""" +Monitor events and output changes to the verbose_log. Nice for debugging purposes. +Arguments: + - events: List of events to monitor +""" +class Test(hass.Hass): + def initialize(self) -> None: + self.log('test') + # id = send_notification( + # self, targets="telegram_monster", message="Repeated message", title="title", when=datetime.datetime.now(), interval=10, iterations=2 + # ) + # self.log("id:{}".format(id)) + # id = send_notification( + # self, targets="telegram_monster", message="Dummy", title="title" + # ) + + def terminate(self): + self.log('terminate') + # for listen_event_handle in self.listen_event_handle_list: + # self.cancel_listen_event(listen_event_handle) + \ No newline at end of file diff --git a/appdaemon/settings/apps/test.yaml b/appdaemon/settings/apps/test.yaml new file mode 100644 index 0000000..534fb4f --- /dev/null +++ b/appdaemon/settings/apps/test.yaml @@ -0,0 +1,3 @@ +test: + module: test + class: Test diff --git a/appdaemon/settings/apps/timers/entity_timer.yaml b/appdaemon/settings/apps/timers/entity_timer.yaml index 801f155..2662462 100644 --- a/appdaemon/settings/apps/timers/entity_timer.yaml +++ b/appdaemon/settings/apps/timers/entity_timer.yaml @@ -7,14 +7,14 @@ led_timer: global_dependencies: - globals -led_window_timer: - module: entity_timer - class: EntityTimer - on_time: "09:00:00" - off_time: "23:00:00" - entity: switch.wifi_socket - global_dependencies: - - globals +# led_window_timer: +# module: entity_timer +# class: EntityTimer +# on_time: "09:00:00" +# off_time: "23:00:00" +# entity: switch.wifi_socket +# global_dependencies: +# - globals xmas_tree_timer: module: entity_timer diff --git a/docker-compose.yml b/docker-compose.yml index ff2a148..ff82df7 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -46,10 +46,13 @@ services: build: context: ./appdaemon/docker args: - VERSION: 3.0.2 + VERSION: 3.0.5 container_name: appdaemon depends_on: - hass + privileged: true + devices: + - /dev/bus/usb:/dev/bus/usb volumes: - ./appdaemon/settings:/conf - ./hass/settings/secrets.yaml:/conf/secrets.yaml:ro diff --git a/hass/settings/configuration/alice.yaml b/hass/settings/configuration/alice.yaml index 2417a7c..1fe1204 100644 --- a/hass/settings/configuration/alice.yaml +++ b/hass/settings/configuration/alice.yaml @@ -9,6 +9,8 @@ filter: include_entities: - input_boolean.alice_night_mode - input_boolean.alice_turn_off_light + - input_boolean.alice_turn_on_coffeemachine + - input_boolean.alice_make_coffee exclude_entities: - switch.plug_158d00020f3a29 diff --git a/hass/settings/configuration/input/input_boolean/alice_automation.yaml b/hass/settings/configuration/input/input_boolean/alice_automation.yaml index d115c90..caa319b 100644 --- a/hass/settings/configuration/input/input_boolean/alice_automation.yaml +++ b/hass/settings/configuration/input/input_boolean/alice_automation.yaml @@ -6,3 +6,10 @@ alice_turn_off_light: name: Выключи свет initial: off +alice_turn_on_coffeemachine: + name: Включи кофемашину + initial: off + +alice_make_coffee: + name: Сделай кофе + initial: off diff --git a/hass/settings/configuration/light/livingroom.yaml b/hass/settings/configuration/light/livingroom.yaml new file mode 100644 index 0000000..ee8becc --- /dev/null +++ b/hass/settings/configuration/light/livingroom.yaml @@ -0,0 +1,6 @@ +# - platform: noolite +# channel: 0 +# name: "salt_lamp" +# scan_interval: 60 +# type: dimmer +# mode: noolite \ No newline at end of file diff --git a/hass/settings/configuration/script/water_counter_set.yaml b/hass/settings/configuration/script/water_counter_set.yaml index bc8b93d..1cc9f77 100644 --- a/hass/settings/configuration/script/water_counter_set.yaml +++ b/hass/settings/configuration/script/water_counter_set.yaml @@ -1,10 +1,10 @@ -# water_counter_set: -# sequence: -# - service: mqtt.publish -# data: -# topic: 'home/bath/bath/cold/set' -# payload: 127110 -# - service: mqtt.publish -# data: -# topic: 'home/bath/bath/hot/set' -# payload: 67270 +water_counter_set: + sequence: + - service: mqtt.publish + data: + topic: 'home/bath/bath/cold/set' + payload: 199820 + - service: mqtt.publish + data: + topic: 'home/bath/bath/hot/set' + payload: 104171 diff --git a/hass/settings/custom_components/yandex_smart_home b/hass/settings/custom_components/yandex_smart_home index d9ef78d..faa54ae 160000 --- a/hass/settings/custom_components/yandex_smart_home +++ b/hass/settings/custom_components/yandex_smart_home @@ -1 +1 @@ -Subproject commit d9ef78dfc5de16d312e3d881ce2e99057fa242ff +Subproject commit faa54aed7fb3379483ef043e6c7bb89e444f5b2c