first commit

This commit is contained in:
Your Name
2024-01-11 15:10:51 +03:00
commit eddf07c652
602 changed files with 67154 additions and 0 deletions
+39
View File
@@ -0,0 +1,39 @@
#SHELL := /bin/bash
ARGS = `arg="$(filter-out $@,$(MAKECMDGOALS))" && echo $${arg:-${1}}`
update_secrets_sample:
@echo "Masking passwords..."
#hass
@cat ./secrets/hass-alt/settings/secrets.yaml | sed "s/\:.*/\: xxxxxxxxx/g" > hass/settings/secrets.yaml.sample #mask passwords
@sed -i "s/latitude:.*/latitude: 1.0/g" hass/settings/secrets.yaml.sample #must be a number
@sed -i "s/longitude:.*/longitude: 1.0/g" hass/settings/secrets.yaml.sample #must be a number
@sed -i "s/aqara1_mac:.*/aqara1_mac: xxxxxxxxxxxx/g" hass/settings/secrets.yaml.sample #len 12
@sed -i "s/aqara1_key:.*/aqara1_key: xxxxxxxxxxxxxxxx/g" hass/settings/secrets.yaml.sample #len 16
@sed -i "s/mirobot_key:.*/mirobot_key: xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx/g" hass/settings/secrets.yaml.sample #len 32
@sed -i "s/powerstrip_key:.*/powerstrip_key: xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx/g" hass/settings/secrets.yaml.sample #len 32
@sed -i "s/telegram_chat:.*/telegram_chat: 0/g" hass/settings/secrets.yaml.sample #must be a number
@sed -i "s/timezone:.*/timezone: Europe\/London/g" hass/settings/secrets.yaml.sample #must be valid
@sed -i "s/telegram_group_chat:.*/telegram_group_chat: 0/g" hass/settings/secrets.yaml.sample #must be valid
@sed -i "s/telegram_monster_chat:.*/telegram_monster_chat: 0/g" hass/settings/secrets.yaml.sample #must be valid
@sed -i "s/purifier_key:.*/purifier_key: xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx/g" hass/settings/secrets.yaml.sample #must be valid
@sed -i "s/philips_downlight_token:.*/philips_downlight_token: xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx/g" hass/settings/secrets.yaml.sample #must be valid
@sed -i "s/wifi_socket_key:.*/wifi_socket_key: xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx/g" hass/settings/secrets.yaml.sample #must be valid
@sed -i "s/modbus_host:.*/modbus_host: modbus.host/g" hass/settings/secrets.yaml.sample #must be valid
@sed -i "s/modbus_port:.*/modbus_port: 9977/g" hass/settings/secrets.yaml.sample #must be valid
@sed -i "s/xiaomi_remote_token:.*/xiaomi_remote_token: xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx/g" hass/settings/secrets.yaml.sample #must be valid
#@jq '(.wifi_ssid, .wifi_pwd, .mqtt_user, .mqtt_pwd) |= "XXXX"' -c sls/settings/config.json > sls/settings/config.json.sample
@echo "Masking env files..."
@find . -name *.env | xargs -I{} cp {} {}.sample
@find . -name *.env.sample | xargs -I{} sed -i "s/\=.*/\=xxxxxxxxx/g" {}
# @cat zigbee2mqtt/settings/configuration.yaml | sed "s/\:.*/\: xxxxxxxxx/g" > zigbee2mqtt/settings/configuration.yaml.sample #mask passwords
commit: update_secrets_sample
git add .
git diff-index --quiet HEAD || git commit -m "$(call ARGS,\"updating configuration\")"
git push
%:
@:
+4
View File
@@ -0,0 +1,4 @@
# home-assistant-alt
"Alternate" home-assistant configurateion (other flat).
+30
View File
@@ -0,0 +1,30 @@
FROM python:latest
VOLUME /conf
VOLUME /certs
EXPOSE 5050
RUN apt-get update && apt-get install -y git
# Copy appdaemon into image
RUN mkdir -p /usr/src/app
WORKDIR /usr/src/app
# Download and build appdaemon, 4.0.0b2
# --branch=4.0.0b1
RUN git clone --recurse-submodules https://github.com/home-assistant/appdaemon.git .
RUN pip3 install requests python-dateutil && pip install --upgrade pip .
# Start script
RUN chmod +x /usr/src/app/dockerStart.sh
RUN pip3 install \
attrs==19.1.0 \
#emp_mos_api==0.12 \
packaging==16.8 \
#python-Levenshtein==0.12.0 \
voluptuous==0.11.5
ADD ./emp_mos_api /emp_mos_api
CMD [ "./dockerStart.sh" ]
Submodule appdaemon/docker/emp_mos_api added at 8354c40657
+35
View File
@@ -0,0 +1,35 @@
logs:
main_log:
filename: STDOUT
appdaemon:
# threads: 10
latitude: !secret home_latitude
longitude: !secret home_longitude
elevation: 0
time_zone: !secret timezone
plugins:
HASS:
type: hass
namespace: default
ha_url: !secret ha_url
token: !secret ha_token
MQTT:
type: mqtt
namespace: mqtt
verbose: True
client_host: !secret mqtt_ip
client_port: 1883
client_user: !secret mqtt_user
client_password: !secret mqtt_password
client_topics:
- home/presence/#
http:
url: !secret ha_url
admin:
api:
hadashboard:
mqtt:
@@ -0,0 +1,98 @@
import appdaemon.plugins.hass.hassapi as hass
import globals
import time
import datetime
import random
from datetime import timedelta
#
# Co2 report.
#
# Args:
# entity - group of entities to send state report
# notify - notify entity to send message
#
# Release Notes
#
# Version 1.0:
# Initial Version
class AirQuality(hass.Hass):
volume = 0.7
co2_max_level = 1000
def initialize(self):
if 'alice' not in self.args or 'co2_sensor' not in self.args or 'ha_panel' not in self.args:
self.error("Please provide alice, co2_sensor, ha_panel in config!")
return
self.timer = self.run_every(self.timer_tick, self.datetime()+timedelta(seconds=10), 1*60*60)
def timer_tick(self, args) -> None:
if 'constraint' in self.args and not self.constrain_input_boolean(self.args['constraint']):
return
home_state = self.get_state(self.args['ha_panel'])
co2_value = int(float(self.get_state(self.args['co2_sensor'])))
if home_state == "disarmed" and self.time_is_between(self.datetime(), '08:00:00', '23:59:59'):
self.log('co2 level: {}'.format(co2_value))
if co2_value > self.co2_max_level:
self.warn_air_quality(co2_value)
def warn_air_quality(self, co2_value):
commands=[
"Уровень ЦО2 достиг отметки {}... Пожалуйста, откройте окно.".format(co2_value),
"Мне кажется у нас душно... Давайте откроем окно?",
"ЦО2 растёт не по дням, а по часам... Уже {}... Давайте проветрим?".format(co2_value),
"Кажется, я давно не была на улице... Давайте откроем окно и сделаем вид что гуляем?",
"Пора открыть окошко, ЦО2 уже {}.".format(co2_value)
]
random.shuffle(commands)
self.say(commands[0])
def command(self, command):
self.call_service('yandex_station/send_command', command='sendText', text=command)
def say(self, command):
volume = self.get_state(self.args['alice'], 'volume_level')
state = self.get_state(self.args['alice'])
self.log('state: {}'.format(state))
if state == "playing":
self.call_service('media_player/media_pause', entity_id=self.args['alice'])
self.call_service('media_player/volume_set', entity_id=self.args['alice'], volume_level=self.volume)
self.log('command = {}'.format(command))
self.call_service('media_player/play_media', entity_id=self.args['alice'], media_content_type='text', media_content_id=command)
self.run_in(self.run_in_volume, 10, volume=volume, state=state)
def run_in_volume(self, kwargs):
if 'volume' not in kwargs or 'state' not in kwargs:
return
volume = kwargs['volume']
state = kwargs['state']
self.log('setting volume back to {}'.format(volume))
self.call_service('media_player/volume_set', entity_id=self.args['alice'], volume_level=volume)
if state == "playing":
self.log('resuming')
# self.call_service('media_player/media_play', entity_id=self.args['alice'])
def time_is_between(
hass, target_dt: datetime, start_time: str,
end_time: str) -> bool:
"""Generalization of AppDaemon's now_is_between method."""
start_time_dt = hass.parse_time(start_time) # type: datetime
end_time_dt = hass.parse_time(end_time) # type: datetime
start_dt = target_dt.replace(
hour=start_time_dt.hour,
minute=start_time_dt.minute,
second=start_time_dt.second)
end_dt = target_dt.replace(
hour=end_time_dt.hour,
minute=end_time_dt.minute,
second=end_time_dt.second)
if end_dt < start_dt:
# Spans midnight
if target_dt < start_dt and target_dt < end_dt:
target_dt = target_dt + timedelta(days=1)
end_dt = end_dt + timedelta(days=1)
return start_dt <= target_dt <= end_dt
@@ -0,0 +1,11 @@
#################################
# HOME PRESENSE #
#################################
airquality:
module: airquality
class: AirQuality
alice: media_player.yandex_station
ha_panel: alarm_control_panel.ha_alarm
co2_sensor: sensor.co2_mainroom_bed
global_dependencies:
- globals
+31
View File
@@ -0,0 +1,31 @@
import appdaemon.plugins.hass.hassapi as hass
import appdaemon.plugins.mqtt.mqttapi as mqtt
import globals
import json
import random
import time
import datetime
class MqttSay(mqtt.Mqtt, hass.Hass):
base_topic = "home/AliceBig/say"
def initialize(self) -> None:
self.log('Initialize Alice say plugin')
self.base_topic = self.args.get('topic', self.base_topic)
self.log('Listening for {}'.format(self.base_topic))
self.mqtt_subscribe(self.base_topic, namespace='mqtt')
self.listen_event(self.event_listener, 'MQTT_MESSAGE', namespace='mqtt')
def event_listener(self, event_name, data, kwargs):
if self.base_topic != data['topic']:
return
self.say(data['payload'])
def command(self, command):
self.call_service('yandex_station/send_command', command='sendText', text=command)
def say(self, command):
# self.call_service('media_player/volume_set', entity_id=self.args['alice'], volume_level=0.6)
self.call_service('media_player/play_media', entity_id=self.args['alice'], media_content_type='text', media_content_id=command)
@@ -0,0 +1,7 @@
mqttSay:
module: mqttSay
class: MqttSay
global_dependencies:
- globals
alice: media_player.yandex_station
topic: "home/yandexStation/say"
@@ -0,0 +1,49 @@
import appdaemon.plugins.hass.hassapi as hass
import globals
import time
#
# Home and away report
# Send report about home state after presence_actions was done.
#
# Args:
# entity - group of entities to send state report
# notify - notify entity to send message
#
# Release Notes
#
# Version 1.0:
# Initial Version
class PresenceAction(hass.Hass):
report_delay = 15
def initialize(self):
if 'alice' not in self.args:
self.error("Please provide alice in config!")
return
self.listen_event(self.away_mode, "away_mode")
self.listen_event(self.return_home_mode, "return_home_mode")
def away_mode(self, event_id, event_args, kwargs):
if 'constraint' in self.args and not self.constrain_input_boolean(self.args['constraint']):
return
self.command('стоп')
def return_home_mode(self, event_id, event_args, kwargs):
if 'constraint' in self.args and not self.constrain_input_boolean(self.args['constraint']):
return
command = 'Добро пожаловать домой.'
self.run_in(self.run_in_say, 59, command=command)
def run_in_say(self, kwargs):
if 'command' not in kwargs:
return
text = kwargs['command']
self.say(text)
def command(self, command):
self.call_service('yandex_station/send_command', command='sendText', text=command)
def say(self, command):
self.log('command = {}'.format(command))
self.call_service('media_player/play_media', entity_id=self.args['alice'], media_content_type='text', media_content_id=command)
@@ -0,0 +1,9 @@
#################################
# HOME PRESENSE #
#################################
presence_action:
module: presence_action
class: PresenceAction
alice: media_player.yandex_station
global_dependencies:
- globals
+29
View File
@@ -0,0 +1,29 @@
#################################################################
## Global
#################################################################
global_modules:
- globals
- automation
- notify
automation:
class: Automation
module: automation
#################################
# CUBE #
#################################
# cube:
# module: cube
# class: Cube
#################################
# NARODMON #
#################################
# narodmon_sensor:
# module: narodmon_sensor
# class: NarodmonSensor
# entity: sensor.lux_outside
# friendly_name: 'Освещенность (Улица)'
# user: 1089
# sensorid: 7574
# interval: 120
+55
View File
@@ -0,0 +1,55 @@
import appdaemon.plugins.hass.hassapi as hass
class Base(hass.Hass):
"""Define a base automation object."""
def initialize(self) -> None:
"""Initialize."""
# self.log('Initialize base')
# Define a holding place for HASS entity IDs:
self.entity_ids = self.args.get('entity_ids', {})
# Define a holding place for any scheduler handles that the automation
# wants to keep track of:
self.handles = {} # type: Dict[str, str]
# Define a holding place for key/value properties for this automation:
self.properties = self.args.get('properties', {})
# Take every dependecy and create a reference to it:
for app in self.args.get('dependencies', []):
if not getattr(self, app, None):
setattr(self, app, self.get_app(app))
class Automation(Base):
"""Define a base automation object."""
def initialize(self) -> None:
"""Initialize."""
super().initialize()
# self.log('Initialize automation')
# Define a reference to the "manager app" – for example, a trash-
# related automation might carry a reference to TrashManager:
if self.args.get('app'):
self.app = getattr(self, self.args['app'])
# Set the entity ID of the input boolean that will control whether
# this automation is enabled or not:
self.enabled_entity_id = None # type: ignore
enabled_config = self.args.get('enabled_config', {})
if enabled_config:
if enabled_config.get('toggle_name'):
self.enabled_entity_id = 'input_boolean.{0}'.format(
enabled_config['toggle_name'])
else:
self.enabled_entity_id = 'input_boolean.{0}'.format(self.name)
# Register any "mode alterations" for this automation – for example,
# perhaps it should be disabled when Vacation Mode is enabled:
mode_alterations = self.args.get('mode_alterations', {})
if mode_alterations:
for mode, value in mode_alterations.items():
mode_app = getattr(self, mode)
mode_app.register_enabled_entity(self.enabled_entity_id, value)
@@ -0,0 +1,41 @@
import appdaemon.plugins.hass.hassapi as hass
import os
#
# App to send notification when doorbell is ringing
#
# Args:
#
# notify = notification platform to send notifications to
# ringtone (optional) = ringtone to play
#
# None
#
# Release Notes
#
# Version 1.0:
# Initial Version
class Doorbell(hass.Hass):
ringtone = 10
gw_mac = ''
def initialize(self):
if "notify" not in self.args or "sensor" not in self.args:
self.error("Please provide notify, sensor in config!")
return
if "gw_mac" in self.args:
self.gw_mac = self.args['gw_mac']
if 'ringtone' in self.args:
self.ringtone = self.args['ringtone']
self.listen_state(self.sensor_trigger, self.args['sensor'])
def sensor_trigger(self, entity, attribute, old, new, kwargs):
if new == None or new == "None" or new == "" or new == "unknown":
return
self.log("doorbell call")
self.log("old: {}, new: {}".format(old, new))
self.call_service("xiaomi_aqara/play_ringtone", gw_mac=self.gw_mac, ringtone_id=self.ringtone, ringtone_vol=100)
self.notify("Звонок в дверь!!!", name = self.args['notify'])
# result = self.send_video()
@@ -0,0 +1,11 @@
#################################
# INTERCOM #
#################################
doorbell:
module: doorbell
class: Doorbell
sensor: sensor.doorbell_button
notify: telegram
gw_mac: !secret aqara1_mac
global_dependencies:
- globals
@@ -0,0 +1,60 @@
import appdaemon.plugins.hass.hassapi as hass
import os
#
# App to send notification when doorbell is ringing
#
# Args:
#
# notify = notification platform to send notifications to
#
# None
#
# Release Notes
#
# Version 1.0:
# Initial Version
class Intercom(hass.Hass):
is_blocked = False
timer = None
def initialize(self):
if "notify" not in self.args or "sensor" not in self.args:
self.error("Please provide notify, sensor in config!")
return
self.listen_state(self.sensor_trigger, self.args['sensor'])
def sensor_trigger(self, entity, attribute, old, new, kwargs):
if old == "" or old == "unknown":
return
if new == "ring" and self.is_blocked == False:
self.log("intercom call")
self.log("old: {}, new: {}".format(old, new))
self.say("Звонок в домофон!!!")
self.notify("Звонок в домофон!!! ({}, {})".format(old, new), name = self.args['notify'])
self.is_blocked = True
self.run_timer()
def run_timer(self):
if self.timer != None:
self.stop_timer()
self.timer = self.run_in(self.unblock_msg, self.args['timeout'])
def stop_timer(self):
if (self.timer == None):
return
self.log('stopping timer.')
self.cancel_timer(self.timer)
self.timer = None
def unblock_msg(self, kwargs):
self.log('stopping timer')
self.is_blocked = False
def say(self, command):
self.log('command = {}'.format(command))
self.call_service('media_player/play_media', entity_id=self.args['alice'], media_content_type='text', media_content_id=command)
@@ -0,0 +1,12 @@
#################################
# INTERCOM #
#################################
intercom:
module: intercom
class: Intercom
sensor: sensor.zintercom_state
notify: telegram
timeout: 30
alice: media_player.yandex_station
global_dependencies:
- globals
@@ -0,0 +1,22 @@
import appdaemon.plugins.hass.hassapi as hass
#
# This app fix event turn off after hass restart
#
# Args:
#
# Release Notes
#
# Version 1.0:
# Initial Version
class AutomationFix(hass.Hass):
def initialize(self):
self.listen_event(self.ha_event, "plugin_started")
def ha_event(self, event_name, data, kwargs):
self.run_in(self.turn_on_automations_timer, 10)
def turn_on_automations_timer(self, kwargs):
self.log('turning on automations')
self.call_service('automation/turn_on', entity_id='group.all_automations')
@@ -0,0 +1,6 @@
#################################
# FIXES #
#################################
automation_fix:
module: automation_fix
class: AutomationFix
+97
View File
@@ -0,0 +1,97 @@
import importlib
import subprocess
from typing import Any, Tuple, Union
#
# Global module
# contain global functions
#
# Args:
#
# None
#
# Release Notes
#
# Version 1.0:
# Initial Version
"""Define various constants."""
CONF_PEOPLE = 'people'
def most_common(the_list: list) -> Any:
"""Return the most common element in a list."""
return max(set(the_list), key=the_list.count)
def check_properties(self, args):
missing = []
for arg in args:
if arg not in self.properties:
missing.append(arg)
if len(missing) > 0:
self.error("Please provide {} in properties!".format(missing))
return False
return True
def check_args(self, args):
missing = []
for arg in args:
if arg not in self.args:
missing.append(arg)
if len(missing) > 0:
self.error("Please provide {} in config!".format(missing))
return False
return True
def check_constaint(self, name='constraint'):
self.log('checking constaint: {}'.format(name))
if name not in self.args:
self.log('script is not constrained')
return True;
constaint_input = self.args[name]
constaint_state = self.get_state(constaint_input)
def get_group_entities(self, group):
attributes = self.get_state(group, attribute = "all")
if (attributes == None):
self.log('Warning: Possible invalid group: {}'.format(group))
return []
if 'attributes' in attributes:
attributes = attributes['attributes']
#group
if "entity_id" in attributes:
return attributes["entity_id"]
#simple item
return [group]
def turn_on(self, entity_id, state):
if entity_id.startswith("light"):
self.call_service("light/turn_off", entity_id = entity_id, transition = 1, brightness = 255)
else:
self.turn_off(entity_id, state)
def turn_off(self, entity_id, state):
if entity_id.startswith("light"):
self.call_service("light/turn_off", entity_id = entity_id, transition = 1)
else:
self.turn_off(entity_id, state)
def notification(self, to, message):
self.notify(message, name = "telegram")
def get_arg(args, key):
key = args[key]
return key
def get_arg_list(args, key):
arg_list = []
if isinstance(args[key], list):
arg = args[key]
else:
arg = (args[key]).split(",")
for key in arg:
arg_list.append(key)
return arg_list
+79
View File
@@ -0,0 +1,79 @@
import appdaemon.plugins.hass.hassapi as hass
import re
#
# This app will create groups in runtime by user defined params
# Based on Rene Tode groups plugin ( hass@reot.org )
#
# Args:
# device_type: sensor # or any devicetype
# entity_part: "any_part"
# entities: # list of entities
# - sensor.any_entity
# hidden: False # or True
# view: True # or False
# assumed_state: False # or True
# friendly_name: Your Friendly Name
# nested_view: True # or False
#
# Release Notes
#
# Version 1.0:
# Initial Version
class Groups(hass.Hass):
def initialize(self):
self.run_in(self.create_groups_timer, 10)
def create_groups_timer(self, kwargs):
entitylist = []
expand_group = self.args.get("expand_group", False)
device_type = self.args.get("device_type", None)
all_entities = self.get_state(device_type)
if "entities" in self.args:
for entity in self.args["entities"]:
found = False
for real_entity in all_entities:
if real_entity.lower() == entity.lower():
self.append_entity(entitylist, entity, expand_group)
found = True
if not found:
for real_entity in all_entities:
if re.match(entity.lower(), real_entity.lower()):
self.append_entity(entitylist, real_entity, expand_group)
if 'entity_part' in self.args:
for entity in all_entities:
if self.args["entity_part"] in entity and entity.lower() not in entitylist:
self.append_entity(entitylist, entity, expand_group)
hidden = self.args.get("hidden", False)
view = self.args.get("view", False)
assumed_state = self.args.get("assumed_state",False)
friendly_name = self.args.get("friendly_name","")
name = "group." + self.name
if self.args.get('orderby', None) == 'friendly_name':
entitylist.sort(key=self.sortByFriendlyName)
self.log("Creating group {} with entities: {}".format(name, entitylist))
self.set_state(name,state="on",attributes={"view": view,"hidden": hidden,"assumed_state": assumed_state,"friendly_name": friendly_name,"entity_id": entitylist, 'description': 'Created and updated from appdaemon ({})'.format(__name__)})
def sortByFriendlyName(self, entity):
return self.get_state(entity, attribute='friendly_name')
def append_entity(self, entitylist, entity, expand_group):
entity = entity.lower()
if (('group.' not in entity and expand_group) or not expand_group) and entity not in entitylist:
entitylist.append(entity)
return
if expand_group and 'group.' in entity:
group_entities = self.get_group_entities(entity)
for group_entity in group_entities:
if group_entity not in entitylist:
entitylist.append(group_entity)
def get_group_entities(self, group):
group = self.get_state(group, attribute = "all")
return group["attributes"]["entity_id"]
@@ -0,0 +1,17 @@
report_entities:
module: groups
class: Groups
friendly_name: Контроль при приходе-уходе
entities: # list of entities
- light.yeelight_color1_286c071065c7
- light.yeelight_color1_34ce008fe328
- light.yeelight_mono1_34ce0087ff33
- light.yeelight_mono1_f0b4290e5444
- light.yeelight_mono1_f0b429a8f6df
- light.yeelight_mono1_286c07f12bed
- light.yeelight_mono1_f0b429a8d936
- switch.light_bath
- switch.light_kitchen
- switch.plug_light_hall
# device_type: light
# entity_part: "_"
@@ -0,0 +1,133 @@
import appdaemon.plugins.hass.hassapi as hass
import globals
import datetime
class LightButton(hass.Hass):
isLocked = False
def initialize(self):
if not globals.check_args(self,['entities', 'sensor']):
return
self.listen_event_handle_list = []
self.listen_event_handle_list.append(self.listen_event(self.event_detected, "xiaomi_aqara.click"))
self.listen_event_handle_list.append(self.listen_state(self.state_change, self.args['sensor'])) #, attribute='action'
def state_change(self, entity, attribute, old, new, kwargs):
if new == "single":
self.log('button_click')
self.make_action_by_mode("off")
if new == "double":
self.log('button_dblclick')
self.make_action_by_mode("on")
def event_detected(self, event_name, data, kwargs):
if data["entity_id"] != self.args["sensor"] or self.isLocked:
return
self.isLocked = True
if data["click_type"] == "single":
self.log('button_click')
self.make_action_by_mode("off")
if data["click_type"] == "double":
self.log('button_dblclick')
self.make_action_by_mode("on")
self.run_in(self.clear_lock, 1)
def clear_lock(self, kwargs):
self.isLocked = False
def make_action_by_mode(self, state):
states = self.fill_states()
control_entity = self.args.get('control_input', None)
if (control_entity != None):
entity_id = next(iter(states))
state = self.get_state(entity_id)
self.set_state(entity_id, self.invert_state(state))
self.set_state(control_entity, state)
return
if len(self.args["entities"]) > 1:
self.toggle_next_with_state(state, states)
def toggle_next_with_state(self, state, states):
for entity_id in states:
entity_state = states[entity_id]
if (entity_state == state):
self.set_state(entity_id, self.invert_state(state))
self.log('toggling {}'.format(entity_id))
return
for entity_id in states:
self.log('toggling {}'.format(entity_id))
self.toggle(entity_id)
def set_state(self, entity_id, state):
if (state == "off"):
self.log('turn_off {}'.format(entity_id))
self.set_off(entity_id)
if (state == "on"):
self.log('turn_on {}'.format(entity_id))
self.set_on(entity_id)
#light
def set_on(self, entity_id):
brightness = 1 #night mode
if self.time_is_between(self.datetime(), '07:00:00', '23:00:00'):
brightness=255 #day mode
if entity_id.startswith("light") and 'color' in entity_id:
self.call_service("homeassistant/turn_on", entity_id = entity_id, transition = 1, brightness = brightness, color_temp=204)
elif entity_id.startswith("light"):
self.call_service("homeassistant/turn_on", entity_id = entity_id, transition = 1, brightness = brightness)
else:
self.turn_on(entity_id)
def set_off(self, entity_id):
if entity_id.startswith("light"):
self.call_service("homeassistant/turn_off", entity_id = entity_id, transition = 1)
else:
self.turn_off(entity_id)
def invert_state(self, state):
if (state == "off"):
return "on"
return "off"
def fill_states(self):
states = {}
for entity_id in self.split_device_list(self.args["entities"]):
state = self.get_state(entity_id)
if (state == None):
self.log("Warning! Device {} not found (state: {}).".format(entity_id, state))
continue
states[entity_id] = state
return states
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 time_is_between(
hass, target_dt: datetime, start_time: str,
end_time: str) -> bool:
"""Generalization of AppDaemon's now_is_between method."""
start_time_dt = hass.parse_time(start_time) # type: datetime
end_time_dt = hass.parse_time(end_time) # type: datetime
start_dt = target_dt.replace(
hour=start_time_dt.hour,
minute=start_time_dt.minute,
second=start_time_dt.second)
end_dt = target_dt.replace(
hour=end_time_dt.hour,
minute=end_time_dt.minute,
second=end_time_dt.second)
if end_dt < start_dt:
# Spans midnight
if target_dt < start_dt and target_dt < end_dt:
target_dt = target_dt + timedelta(days=1)
end_dt = end_dt + timedelta(days=1)
return start_dt <= target_dt <= end_dt
@@ -0,0 +1,29 @@
#################################
# LIGHT BUTTONS #
#################################
# light_button_bath:
# module: lightbutton
# class: LightButton
# sensor: binary_sensor.wall_switch_bath
# entities: light.bath
# # control_input: input_boolean.bath_light_control
# global_dependencies:
# - globals
light_button_main:
module: lightbutton
class: LightButton
sensor: sensor.bed_button
entities: light.yeelight_color1_34ce008fe328,light.yeelight_color1_286c071065c7,light.yeelight_mono1_34ce0087ff33,light.yeelight_mono1_f0b4290e5444,light.yeelight_mono1_f0b429a8f6df,light.yeelight_mono1_286c07f12bed,light.yeelight_mono1_f0b429a8d936,light.lamp1
global_dependencies:
- globals
light_button_hall:
module: lightbutton
class: LightButton
sensor: sensor.0x00158d000183a5fe_action
entities: switch.plug_light_hall
global_dependencies:
- globals
@@ -0,0 +1,123 @@
import appdaemon.plugins.hass.hassapi as hass
import globals
import datetime
#
# This app will use motion sensors to turn on and off control_entity for 'timeout' time.
# It work with xiaomi motion sensors.
# main algorithm: when sensor turns on - turn on the light; when sensor is off - set timer for 'timeout';
# if sensor goes on - turn off the timer; when timer was hit - turn off the light.
#
# Args:
#
# sensor = motion sensor to use (will work with other sensor types too)
# timeout = timeout after sensor will turned off (keep in mind that xiaomi motion sensor has own timeout!)
# control_entity = entity, that will be controlled (example: group of light bulbs)
# constraint = you can define constraint (input_boolean), that will turn on and off this app.
# after_sundown (optionally) - whether to only trigger after sundown. example: True
# Release Notes
#
# Version 1.0:
# Initial Version
class LightControl(hass.Hass):
timer = None
def initialize(self):
if not globals.check_args(self, ["sensor", "timeout", "control_entity"]):
return
self.listen_event_handle_list = []
self.listen_event_handle_list.append(self.listen_state(self.sensor_trigger, self.args['sensor']))
def sensor_trigger(self, entity, attribute, old, new, kwargs):
if 'constraint' in self.args and not self.constrain_input_boolean(self.args['constraint']):
return
if 'after_sundown' in self.args and self.args['after_sundown'] and not self.sun_down():
# self.log('Sun is up, and we will turn it only after sundown.')
return
vacuum_state = "docked"
ha_panel_state = ""
if "vacuum" in self.args:
vacuum_state = self.get_state(self.args["vacuum"])
if "ha_panel" in self.args:
ha_panel_state = self.get_state(self.args["ha_panel"])
#Если работает пылесос и сработал датчик движения
if ha_panel_state == "armed_away" and vacuum_state == 'cleaning':
self.log('Motion sensor is triggered, but vacuum is cleaning.')
return
#sensor is off
if new == 'off' and old == 'on':
self.log('Sensor is off - running timer for {}s.'.format(self.args['timeout']))
self.run_timer()
#sensor is on
if new == 'on' and old == 'off':
#turnin on control entity and wait while sensor will be off, than run timer.
self.control_entity_on()
self.stop_timer()
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)
############ TIMER ########################
def run_timer(self):
if self.timer != None:
self.stop_timer()
self.timer = self.run_in(self.control_entity_off, self.args['timeout'])
def control_entity_on(self):
#if 'constraint' is off - we dont need to do anything
if 'constraint' in self.args and not self.constrain_input_boolean(self.args['constraint']):
return
entity = self.args['control_entity']
if not self.time_is_between(self.datetime(), '07:00:00', '23:00:00') and 'control_night_entity' in self.args:
entity=self.args['control_night_entity'] #day mode
if self.get_state(entity) == 'off':
self.turn_on(entity)
self.log("Light on ({}).".format(entity))
def control_entity_off(self, kwargs):
#if 'constraint' is off - we dont need to do anything
if 'constraint' in self.args and not self.constrain_input_boolean(self.args['constraint']):
return
entity = self.args['control_entity']
if not self.time_is_between(self.datetime(), '07:00:00', '23:00:00') and 'control_night_entity' in self.args:
entity=self.args['control_night_entity'] #day mode
if self.get_state(entity) == 'on':
self.turn_off(entity)
self.log("Light off.")
self.stop_timer()
def stop_timer(self):
if (self.timer == None):
return
self.log('stopping timer.')
self.cancel_timer(self.timer)
self.timer = None
def time_is_between(
hass, target_dt: datetime, start_time: str,
end_time: str) -> bool:
"""Generalization of AppDaemon's now_is_between method."""
start_time_dt = hass.parse_time(start_time) # type: datetime
end_time_dt = hass.parse_time(end_time) # type: datetime
start_dt = target_dt.replace(
hour=start_time_dt.hour,
minute=start_time_dt.minute,
second=start_time_dt.second)
end_dt = target_dt.replace(
hour=end_time_dt.hour,
minute=end_time_dt.minute,
second=end_time_dt.second)
if end_dt < start_dt:
# Spans midnight
if target_dt < start_dt and target_dt < end_dt:
target_dt = target_dt + timedelta(days=1)
end_dt = end_dt + timedelta(days=1)
return start_dt <= target_dt <= end_dt
@@ -0,0 +1,25 @@
light_control_hall:
module: lightcontrol
class: LightControl
sensor: binary_sensor.motion_sensor_hall
control_entity: switch.plug_light_hall
control_night_entity: light.gateway
timeout: 240
constraint: input_boolean.hall_light_control
ha_panel: alarm_control_panel.ha_alarm
vacuum: vacuum.xiaomi_vacuum_cleaner
global_dependencies:
- globals
light_control_kitchen:
module: lightcontrol
class: LightControl
sensor: group.kitchen_motion_sensors
control_entity: switch.light_kitchen
timeout: 600
constraint: input_boolean.kitchen_light_control
ha_panel: alarm_control_panel.ha_alarm
vacuum: vacuum.xiaomi_vacuum_cleaner
global_dependencies:
- globals
@@ -0,0 +1,94 @@
import appdaemon.plugins.hass.hassapi as hass
#
# This app will use motion sensors to turn on and off control_entity for 'timeout' time.
# It work with xiaomi motion sensors.
# main algorithm: when sensor turns on - turn on the light; when sensor is off - set timer for 'timeout';
# if sensor goes on - turn off the timer; when timer was hit - turn off the light.
#
# Args:
#
# sensor = motion sensor to use (will work with other sensor types too)
# timeout = timeout after sensor will turned off (keep in mind that xiaomi motion sensor has own timeout!)
# control_entity = entity, that will be controlled (example: group of light bulbs)
# constraint = you can define constraint (input_boolean), that will turn on and off this app.
#
# Release Notes
#
# Version 1.0:
# Initial Version
class LightControlBath(hass.Hass):
timer = None
door_state_changed = None
def initialize(self):
if "sensor" not in self.args or 'door_sensor' not in self.args or "timeout" not in self.args or "control_entity" not in self.args:
self.error("Please provide sensor, door_sensor, control_entity and timeout in config!")
return
self.listen_state(self.sensor_trigger, self.args['sensor'])
self.listen_state(self.door_sensor_trigger, self.args['door_sensor'])
def sensor_trigger(self, entity, attribute, old, new, kwargs):
#sensor is off
if new == 'off' and old == 'on':
self.log('door_state_changed:{}'.format(self.door_state_changed))
if self.door_state_changed == False:
return
self.log('Sensor is off - running timer for {}s.'.format(self.args['timeout']))
self.run_timer()
#sensor is on
if new == 'on' and old == 'off':
#turnin on control entity and wait while sensor will be off, than run timer.
self.control_entity_on()
self.stop_timer()
door_state = self.get_state(self.args['door_sensor'])
if door_state == "off":
self.door_state_changed = False
else :
self.door_state_changed = None
def door_sensor_trigger(self, entity, attribute, old, new, kwargs):
#door is open
sensor_state = self.get_state(self.args['sensor'])
if new == 'on' and old == 'off' and sensor_state == 'off':
self.log('door is opened now, runnig the timer')
if self.timer == None:
self.run_timer()
if self.door_state_changed == False:
self.door_state_changed = True
self.log('door_state_changed:{}'.format(self.door_state_changed))
############ TIMER ########################
def control_entity_on(self):
#if 'constraint' is off - we dont need to do anything
if 'constraint' in self.args and not self.constrain_input_boolean(self.args['constraint']):
return
if self.get_state(self.args['control_entity']) == 'off':
self.turn_on(self.args['control_entity'])
self.log("Light on.")
def control_entity_off(self, kwargs):
#if 'constraint' is off - we dont need to do anything
if 'constraint' in self.args and not self.constrain_input_boolean(self.args['constraint']):
return
if self.door_state_changed == False:
self.log('Motion is detected inside and door is still closed - dont turn off the light')
return
if self.get_state(self.args['control_entity']) == 'on':
self.turn_off(self.args['control_entity'])
self.log("Light off.")
self.stop_timer()
def run_timer(self):
if self.timer != None:
self.stop_timer()
self.timer = self.run_in(self.control_entity_off, self.args['timeout'])
def stop_timer(self):
if (self.timer == None):
return
self.log('stopping timer.')
self.cancel_timer(self.timer)
self.timer = None
@@ -0,0 +1,13 @@
#################################
# LIGHT CONTROL #
#################################
light_control_bath:
module: lightcontrol_bath
class: LightControlBath
sensor: binary_sensor.motion_sensor_bath
door_sensor: binary_sensor.door_window_sensor_bath
control_entity: switch.light_bath
timeout: 240
constraint: input_boolean.bath_light_control
global_dependencies:
- globals
@@ -0,0 +1,58 @@
import datetime
from automation import Automation, Base # type: ignore
#
# App to send email report for low balance
#
# Args:
#
# threshold = value below which battery levels are reported and email is sent
# always_send = set to 1 to override threshold and force send
#
# None
#
# Release Notes
#
# Version 1.0:
# Initial Version
class Balance(Base):
def initialize(self):
super().initialize()
self.check_balance({"force": 1})
self.run_daily(self.check_balance, datetime.time(10, 0, 0))
def check_balance(self, kwargs):
if 'constraint' in self.args and not self.constrain_input_boolean(self.args['constraint']):
return
values = {}
low = []
for key in self.entity_ids:
device = self.entity_ids[key]
balance = self.get_state(device)
if balance != None and self.is_number(balance):
if float(balance) < self.args["threshold"]:
low.append(device)
values[device] = balance
message=""
name = "Алтуфьево"
if 'name' in self.args:
name = self.args['name']
if low:
message += "Пожалуйста пополните баланс ({}): (< {}) \n".format(name, self.args["threshold"])
for device in low:
message += " - {}: {}\n".format(self.friendly_name(device), values[device])
message += "\n\n"
if low or ("always_send" in self.args and self.args["always_send"] == 1) or ("force" in kwargs and kwargs["force"] == 1):
if message != "":
self.notify(message, name = "telegram") #name = "telegram_monster"
def is_number(self, s):
try:
float(s)
return True
except ValueError:
return False
@@ -0,0 +1,30 @@
#################################
# SENSOR BATTERY LOW REMINDER #
#################################
balance_low_report:
class: Balance
module: balance
constraint: input_boolean.balance_report
name: Алтуфьево
entity_ids:
mosenergosbyt_balance: sensor.power_mosenergosbyt_balance
epd_balance: sensor.epd_balance
always_send: 0
threshold: -100
notify: telegram
global_dependencies:
- globals
balance_low_report_belorechenskaya:
class: Balance
module: balance
constraint: input_boolean.balance_report
name: Белореченская
entity_ids:
mosenergosbyt_balance: sensor.power_mosenergosbyt_belorechenskaya_balance
epd_balance: sensor.epd_balance_belorechenskaya
always_send: 0
threshold: -100
notify: telegram
global_dependencies:
- globals
@@ -0,0 +1,58 @@
import appdaemon.plugins.hass.hassapi as hass
import datetime
#
# App to send email report for devices running low on battery
#
# Args:
#
# threshold = value below which battery levels are reported and email is sent
# always_send = set to 1 to override threshold and force send
#
# None
#
# Release Notes
#
# Version 1.0:
# Initial Version
class Battery(hass.Hass):
def initialize(self):
#self.check_batteries({"force": 1})
time = datetime.time(10, 0, 0)
self.run_daily(self.check_batteries, time)
# self.check_batteries(None)
def check_batteries(self, kwargs):
if 'constraint' in self.args and not self.constrain_input_boolean(self.args['constraint']):
return
devices = self.get_state()
values = {}
low = []
for deviceName in devices:
device = devices[deviceName]
if device == None:
continue
battery = None
if "attributes" in device:
if "battery" in device["attributes"]:
battery = device["attributes"]["battery"]
if "battery_level" in device["attributes"]:
battery = device["attributes"]["battery_level"]
if battery != None:
if battery < self.args["threshold"] and battery != 0:
low.append(deviceName)
values[deviceName] = battery
message=""
if low:
message += "У следующих датчиков садится батарея: (< {}) \n".format(self.args["threshold"])
for device in low:
message += "{}: {}%\n".format(self.friendly_name(device), values[device])
message += "\n\n"
if low or ("always_send" in self.args and self.args["always_send"] == 1) or ("force" in kwargs and kwargs["force"] == 1):
if message != "":
self.notify(message, name = "telegram")
@@ -0,0 +1,12 @@
#################################
# SENSOR BATTERY LOW REMINDER #
#################################
sensor_battery_low_report:
class: Battery
module: battery
constraint: input_boolean.battery_report
always_send: 0
threshold: 7
notify: telegram
global_dependencies:
- globals
@@ -0,0 +1,59 @@
import appdaemon.plugins.hass.hassapi as hass
import datetime
#
# App to send email report for devices running low on battery
#
# Args:
#
# threshold = value below which battery levels are reported and email is sent
# always_send = set to 1 to override threshold and force send
#
# None
#
# Release Notes
#
# Version 1.0:
# Initial Version
class Flower(hass.Hass):
def initialize(self):
#self.check_batteries({"force": 1})
time = datetime.time(21, 0, 0)
self.run_daily(self.check_flowers, time)
# self.log('---------------------')
# self.check_flowers([])
def check_flowers(self, kwargs):
if 'constraint' in self.args and not self.constrain_input_boolean(self.args['constraint']):
return
devices = self.get_state()
values = {}
low = []
for deviceName in devices:
device = devices[deviceName]
if device == None:
continue
if '_soil_moisture' not in deviceName or 'avocado' not in deviceName:
continue
# self.log('found: {}'.format(device))
moisture = int(device['state'])
if moisture != None:
if moisture < self.args["threshold"] and moisture != 0:
low.append(deviceName)
values[deviceName] = moisture
message=""
if low:
message += "Пора полить цветочки: (< {}) \n".format(self.args["threshold"])
for device in low:
message += "{}: {}%\n".format(self.friendly_name(device), values[device])
message += "\n\n"
if low or ("always_send" in self.args and self.args["always_send"] == 1) or ("force" in kwargs and kwargs["force"] == 1):
if message != "":
self.notify(message, name = "telegram")
self.call_service('media_player/play_media', entity_id=self.args['alice'], media_content_type='text', media_content_id="Пора полить цветочки.")
@@ -0,0 +1,13 @@
#################################
# SENSOR BATTERY LOW REMINDER #
#################################
sensor_flower_moisture_low_report:
class: Flower
module: flower
constraint: input_boolean.flower_report
alice: media_player.yandex_station
always_send: 0
threshold: 60
notify: telegram
global_dependencies:
- globals
@@ -0,0 +1,28 @@
import appdaemon.plugins.hass.hassapi as hass
import datetime
#
# App to send reports about unsuccessfull auths
#
# Args:
#
# threshold = value below which battery levels are reported and email is sent
# always_send = set to 1 to override threshold and force send
#
# None
#
# Release Notes
#
# Version 1.0:
# Initial Version
class Security(hass.Hass):
def initialize(self):
self.log('---------------------')
self.listen_event_handle_list = []
self.listen_event_handle_list.append(self.listen_state(self.state_change, 'persistent_notification.http_login', attribute='message'))
def state_change(self, entity, attribute, old, new, kwargs):
notifier = self.args.get('notify', 'telegram')
self.notify(new, name = notifier)
self.log(new)
@@ -0,0 +1,10 @@
#################################
# INVALID AUTH REPORT #
#################################
invalid_auth_report:
class: Security
module: security
alice: media_player.yandex_station
notify: telegram
global_dependencies:
- globals
@@ -0,0 +1,57 @@
import appdaemon.plugins.hass.hassapi as hass
from datetime import timedelta
import datetime
import globals
import requests
#
# App to monitor entity update time.
#
# Args:
#
#
# None
#
# Release Notes
#
# Version 1.0:
# Initial Version
class Watchdog(hass.Hass):
listen_event_handle_list = []
timezone = 0
def initialize(self):
if not globals.check_args(self, ['check_interval', 'timeout']):
return
self.timer = self.run_every(self.watchdog_check, self.datetime()+timedelta(seconds=1), self.args['check_interval'])
def watchdog_check(self, kwargs):
if 'constraint' in self.args and not self.constrain_input_boolean(self.args['constraint']):
self.log('disabled by constraint')
return
entities = []
if 'entity' in self.args:
entities.append(self.args['entity'])
if 'entities' in self.args:
for entity in self.args['entities']:
entities.append(entity)
# self.log(entities)
for entity in entities:
# entity = self.args['entity']
attributes = self.get_state(entity, attribute='all')
if not 'last_changed' in attributes:
continue
now = datetime.datetime.now()
last_changed = attributes['last_changed']
last_changed_date = datetime.datetime.strptime(last_changed, '%Y-%m-%dT%H:%M:%S.%f+00:00') + timedelta(hours=self.timezone)
time_diff = now - last_changed_date
# self.log('{}: {}, {}, {} (now: {})'.format(entity, last_changed, last_changed_date, time_diff, now))
if time_diff.seconds > self.args['timeout']:
self.log('Entity {} was changed {} seconds ago.'.format(entity, time_diff.seconds))
friendly_name = self.friendly_name(entity)
message = "Датчик {} ({}) не обновлялся в течении {} секунд.".format(friendly_name, entity, self.args['timeout'])
self.notify(message, name = self.args.get('notify', "telegram"))
@@ -0,0 +1,13 @@
# uptime_watchdog:
# class: Watchdog
# module: watchdog
# constraint: input_boolean.watchdog_report
# entities:
# - sensor.uptime_drink_filter
# - sensor.uptime_sls
# - sensor.uptime_watercontrol
# check_interval: 60
# timeout: 900
# notify: telegram_monster
# global_dependencies:
# - globals
@@ -0,0 +1,109 @@
import appdaemon.plugins.hass.hassapi as hass
import datetime
import globals
#
# App to send notifications when counters state needs attention
#
# Args:
#
#
# None
#
# Release Notes
#
# Version 1.0:
# Initial Version
class WaterCountersAlarm(hass.Hass):
checkup_threasold = 31
send_threasold = 31
def initialize(self):
if not globals.check_args(self, ['checkup_threasold', 'send_threasold']):
return
self.checkup_threasold = self.args['checkup_threasold']
self.send_threasold = self.args['send_threasold']
self.check_send_dates({"force": 1})
self.run_daily(self.check_checkup_dates, datetime.time(10, 0, 0))
self.run_daily(self.check_send_dates, datetime.time(10, 1, 0))
#self.check_checkup_dates([])
def check_send_dates(self, kwargs):
if 'constraint' in self.args and not self.constrain_input_boolean(self.args['constraint']):
return
devices = self.get_state()
if devices == None:
return
values = {}
send_devices = []
for deviceName in devices:
device = devices[deviceName]
if (device == None):
continue
send_date = None
if "attributes" in device:
if "date" in device["attributes"]:
send_date = device["attributes"]["date"]
if send_date != None:
self.log('found device {} with send date {}'.format(deviceName, send_date))
if send_date == "":
send_devices.append(deviceName)
else:
send_date_date = None
if '-' in send_date:
send_date_date = datetime.datetime.strptime(send_date, '%Y-%m-%d')
else:
send_date_date = datetime.datetime.strptime(send_date, '%d.%m.%Y')
if send_date_date < (datetime.datetime.now() - datetime.timedelta(days=self.send_threasold)):
send_devices.append(deviceName)
values[deviceName] = send_date
message=""
if send_devices:
message += "ВНИМАНИЕ! У вас есть счетчики, показания по которым давно не передавались:\n"
for device in send_devices:
message += "- {}: {}\n".format(self.friendly_name(device), values[device])
message += "\n\n"
if send_devices or ("always_send" in self.args and self.args["always_send"] == 1) or ("force" in kwargs and kwargs["force"] == 1):
if message != "":
self.notify(message, name = "telegram")
def check_checkup_dates(self, kwargs):
if 'constraint' in self.args and not self.constrain_input_boolean(self.args['constraint']):
return
entities = self.get_state()
if entities == None:
return
values = {}
checkup_devices = []
for entity_id in entities:
entity = entities[entity_id]
if entity == None:
# self.log('not found {} in entities list.'.format(entity_id))
continue
checkup = None
if "attributes" in entity:
if "checkup" in entity["attributes"]:
checkup = entity["attributes"]["checkup"]
if checkup != None:
self.log('found device {} with checkup date {}'.format(entity_id, checkup))
checkup_date = datetime.datetime.strptime(checkup, '%Y-%m-%d')
if (checkup_date - datetime.timedelta(days=self.checkup_threasold)) < datetime.datetime.now():
checkup_devices.append(entity_id)
values[entity_id] = checkup
message=""
if checkup_devices:
message += "ВНИМАНИЕ! У вас есть счетчик, нуждающийся в поверке:\n"
for device in checkup_devices:
message += "- {}: {}\n".format(self.friendly_name(device), values[device])
message += "\n\n"
if checkup_devices or ("always_send" in self.args and self.args["always_send"] == 1) or ("force" in kwargs and kwargs["force"] == 1):
if message != "":
self.notify(message, name = "telegram")
@@ -0,0 +1,11 @@
#################################
# WATER COUNTERS ALARM #
#################################
water_counters:
class: WaterCountersAlarm
module: water_counters
constraint: input_boolean.water_counters_report
checkup_threasold: 31
send_threasold: 31
global_dependencies:
- globals
+63
View File
@@ -0,0 +1,63 @@
import appdaemon.plugins.hass.hassapi as hass
#
# Notification system
#
# Args:
#
# Release Notes
#
# Version 1.0:
# Initial Version
class BaseNotifyTarget():
def notify(self, hass, message, person='*'):
hass.log('notifying person {} with message: {}'.format(person, message))
class AliceNotifyTarget(BaseNotifyTarget):
alice = 'media_player.yandex_station'
def notify(self, hass, message, person='*'):
msg = message
if person == 'danil':
msg = "Данил, " + msg
elif person == 'sveta':
msg = "Света, " + msg
hass.log('notifying over alice person {} with message: {}'.format(target, message))
self.say(hass, msg)
def say(self, hass, message):
hass.log('message = {}'.format(message))
hass.call_service('media_player/play_media', entity_id=self.alice, media_content_type='text', media_content_id=message)
class TelegramNotifyTarget(BaseNotifyTarget):
target_default = 'telegram'
target_sveta = 'telegram_sveta'
target_danil = 'telegram_monster'
def notify(self, hass, message, person='*'):
target = self.target_default
if person == 'danil':
target = self.target_danil
elif person == 'sveta':
target = self.target_sveta
hass.log('notifying over telegram person {} with message: {}'.format(target, message))
hass.notify(message, name=target)
class Notification(hass.Hass):
home_targets = [AliceNotifyTarget()]
away_targets = [TelegramNotifyTarget()]
def initialize(self):
self.log('Init')
def notify_person(self, message, target='*', repeat = 1, delay = 60, onEvent = '', at=None):
notifyTarget = None
away_mode = True
if (away_mode):
notifyTarget = self.away_targets[0]
else:
notifyTarget = self.home_targets[0]
self.log('message: {}'.format(message))
notifyTarget.notify(self, message, person=target)
+3
View File
@@ -0,0 +1,3 @@
notify:
module: notify
class: Notification
@@ -0,0 +1,46 @@
import appdaemon.plugins.hass.hassapi as hass
#
# Listen for presence sensor change state and change alarm control panel state.
#
# Args:
# sensor - home presence 'sensor'
# ha_panel - alarm control panel entity (to arm and disarm).
# constraint - (optional, input_boolen), if turned off - alarm panel will be not armed\disarmed.
#
# Release Notes
#
# Version 1.0:
# Initial Version
class AlarmPanelBySensor(hass.Hass):
def initialize(self):
if "sensor" not in self.args or "ha_panel" not in self.args:
self.error("Please provide sensor and ha_panel in config!")
return
self.listen_state(self.sensor_trigger, self.args['sensor'])
self.listen_event(self.ha_event, "ha_started")
def ha_event(self, event_name, data, kwargs):
self.log('Starting up!')
state = self.get_state(self.args['sensor'])
self.log('Updating alarm_control_panel state: {}'.format(state))
if state == "off":
self.away_mode()
def sensor_trigger(self, entity, attribute, old, new, kwargs):
self.log("{} turned {}".format(entity, new))
if new == "off" and old == "on":
self.away_mode()
if new == "on" and old == "off":
self.return_home_mode()
def away_mode(self):
if 'constraint' in self.args and not self.constrain_input_boolean(self.args['constraint']):
return
self.call_service("alarm_control_panel/alarm_arm_away", entity_id = self.args['ha_panel'])
def return_home_mode(self):
if 'constraint' in self.args and not self.constrain_input_boolean(self.args['constraint']):
return
self.call_service("alarm_control_panel/alarm_disarm", entity_id = self.args['ha_panel'])
@@ -0,0 +1,11 @@
#################################
# HOME PRESENSE #
#################################
alarm_panel:
module: alarm_panel
class: AlarmPanelBySensor
sensor: sensor.presence_home
ha_panel: alarm_control_panel.ha_alarm
constraint: input_boolean.presence_control
global_dependencies:
- globals
@@ -0,0 +1,77 @@
import appdaemon.plugins.hass.hassapi as hass
#
# Listen for Alarm panel state change and fire an events ('away_mode' and 'return_home_mode')
#
# Args:
# ha_panel - alarm control panel entity.
# Release Notes
#
# Version 1.0:
# Initial Version
class AlarmPanelAction(hass.Hass):
extended_timeout = 5*60
timers = []
def initialize(self):
if "ha_panel" not in self.args:
self.error("Please provide ha_panel in config!")
return
self.listen_state(self.ha_panel_trigger, self.args['ha_panel'])
extended_timeout = self.args.get('extended_timeout', self.extended_timeout)
self.log('extended_timeout: {}'.format(self.extended_timeout))
def ha_panel_trigger(self, entity, attribute, old, new, kwargs):
# не срабатываем на triggered.
if new == "armed_away" and old != "triggered":
self.away_mode()
if new == "disarmed" and old == "armed_away":
self.return_home_mode()
def away_mode(self):
self.log('away_mode')
self.fire_event('away_mode')
if 'presence_extended' in self.args:
self.fire_event('presence_extended_just_left')
self.set_mode('Just Left')
self.cancel_current_timer()
self.timers.append(self.run_in(self.run_in_set_mode, self.extended_timeout, mode='Away'))
def return_home_mode(self):
self.log('return_home_mode')
self.fire_event('return_home_mode')
if 'presence_extended' in self.args:
self.set_mode('Just Arrived')
self.fire_event('presence_extended_just_arrived')
self.cancel_current_timer()
self.timers.append(self.run_in(self.run_in_set_mode, self.extended_timeout, mode='Home'))
def set_mode(self, mode):
if 'presence_extended' not in self.args:
return
entity_id = self.args['presence_extended']
self.log('setting mode direct: {}={}'.format(entity_id, mode))
self.call_service("input_select/select_option", entity_id = entity_id, option = mode)
def run_in_set_mode(self, kwargs):
if not 'mode' in kwargs:
return
mode = kwargs['mode']
entity_id = self.args['presence_extended']
event = 'presence_extended_{}'.format(mode.lower())
self.log('firing event: {}'.format(event))
self.fire_event(event)
self.log('setting mode by timer: {}={}'.format(entity_id, mode))
self.call_service("input_select/select_option", entity_id = entity_id, option = mode)
def cancel_current_timer(self):
for timer in self.timers:
self.log('canceling timer')
self.cancel_timer(timer)
self.timers = []
@@ -0,0 +1,11 @@
#################################
# HOME PRESENSE #
#################################
alarm_panel_action:
module: alarm_panel_action
class: AlarmPanelAction
ha_panel: alarm_control_panel.ha_alarm
presence_extended: input_select.presence_extended
extended_timeout: 300
global_dependencies:
- globals
@@ -0,0 +1,32 @@
import appdaemon.plugins.hass.hassapi as hass
#
# Light control by presence events
# Listen for presence events and change light state
#
# Args:
# entities - entities to turn on and off when presence is changed.
# Release Notes
#
# Version 1.0:
# Initial Version
class EntitiesByPresence(hass.Hass):
def initialize(self):
if 'away_entities' not in self.args or 'return_entities' not in self.args:
self.error("Please provide away_entities and return_entities in config!")
return
self.listen_event(self.away_mode, "away_mode")
self.listen_event(self.return_home_mode, "return_home_mode")
def away_mode(self, event_id, event_args, kwargs):
if 'constraint' in self.args and not self.constrain_input_boolean(self.args['constraint']):
return
for entity_id in self.args["away_entities"]:
self.turn_off(entity_id)
def return_home_mode(self, event_id, event_args, kwargs):
if 'constraint' in self.args and not self.constrain_input_boolean(self.args['constraint']):
return
for entity_id in self.args["return_entities"]:
self.turn_on(entity_id)
@@ -0,0 +1,23 @@
#################################
# HOME PRESENSE #
#################################
entities_action:
module: entities_action
class: EntitiesByPresence
away_entities:
- light.yeelight_color1_286c071065c7
- light.yeelight_color1_34ce008fe328
- light.yeelight_mono1_34ce0087ff33
- light.yeelight_mono1_f0b4290e5444
- light.yeelight_mono1_f0b429a8f6df
- light.yeelight_mono1_286c07f12bed
- light.yeelight_mono1_f0b429a8d936
- light.lamp1
- switch.light_bath
- switch.light_kitchen
- switch.plug_light_hall
return_entities:
- switch.plug_light_hall
- light.yeelight_color1_34ce008fe328
global_dependencies:
- globals
@@ -0,0 +1,107 @@
import appdaemon.plugins.hass.hassapi as hass
import globals
import datetime
#
# Purifier controller
# Turn on and off purifier by light sensor and timers
#
# Args:
#
# None
#
# Release Notes
#
# Version 1.0:
# Initial Version
class Purifier(hass.Hass):
away_fan_speed = 8 #1-14
listen_event_handle_list = []
mode = 'auto'
def initialize(self):
if not globals.check_args(self, ["on_time", "off_time"]):
return
on_time = self.parse_time(self.args["on_time"])
off_time = self.parse_time(self.args["off_time"])
self.on_timer = self.run_daily(self.day_mode_timer_event, on_time)
self.off_timer = self.run_daily(self.night_mode_timer_event, off_time)
self.listen_event_handle_list.append(self.listen_event(self.away_mode_event, "away_mode"))
self.listen_event_handle_list.append(self.listen_event(self.return_home_mode_event, "return_home_mode"))
self.listen_event_handle_list.append(self.listen_state(self.state_change, self.args['entity'], attribute='illuminance'))
def state_change(self, entity, attribute, old, new, kwargs):
presence_state = self.get_state(self.args['ha_panel'])
if presence_state != "disarmed":
# self.log('Nobody home, ignoring illuminance changes.')
return
self.log("{} changed from {} to {}".format(attribute, old, new))
if old > 0 and new == 0 and self.time_is_between(self.datetime(), self.args["on_time"], self.args["off_time"]):
self.night_mode()
elif old == 0 and new > 0 and self.time_is_between(self.datetime(), self.args["on_time"], self.args["off_time"]):
self.auto_mode()
#self.auto_mode()
def return_home_mode_event(self, event_id, event_args, kwargs):
self.auto_mode()
def away_mode_event(self, event_id, event_args, kwargs):
self.away_mode()
def night_mode_timer_event(self, kwargs):
self.night_mode()
def day_mode_timer_event(self, kwargs):
self.auto_mode()
def away_mode(self):
self.mode='away'
self.log('Set purifier mode to favorite, speed: {}'.format(self.away_fan_speed))
if 'entity' in self.args:
self.call_service("fan/set_speed", entity_id = self.args['entity'], speed='favorite')
self.call_service("fan/xiaomi_miio_set_favorite_level", entity_id = self.args['entity'], level=self.away_fan_speed)
self.call_service("fan/xiaomi_miio_set_led_on", entity_id = self.args['entity'])
# self.call_service("fan/xiaomi_miio_set_buzzer_on", entity_id = self.args['entity'])
def auto_mode(self):
self.mode='auto'
self.log('Set purifier mode to auto.')
if 'entity' in self.args:
self.call_service("fan/set_speed", entity_id = self.args['entity'], speed='auto')
self.call_service("fan/xiaomi_miio_set_led_on", entity_id = self.args['entity'])
# self.call_service("fan/xiaomi_miio_set_buzzer_on", entity_id = self.args['entity'])
def night_mode(self):
self.mode='night'
self.log('Set purifier mode to silent.')
if 'entity' in self.args:
self.call_service("fan/xiaomi_miio_set_buzzer_off", entity_id = self.args['entity'])
self.call_service("fan/set_speed", entity_id = self.args['entity'], speed='silent')
self.call_service("fan/xiaomi_miio_set_led_off", entity_id = self.args['entity'])
def time_is_between(
hass, target_dt: datetime, start_time: str,
end_time: str) -> bool:
"""Generalization of AppDaemon's now_is_between method."""
start_time_dt = hass.parse_time(start_time) # type: datetime
end_time_dt = hass.parse_time(end_time) # type: datetime
start_dt = target_dt.replace(
hour=start_time_dt.hour,
minute=start_time_dt.minute,
second=start_time_dt.second)
end_dt = target_dt.replace(
hour=end_time_dt.hour,
minute=end_time_dt.minute,
second=end_time_dt.second)
if end_dt < start_dt:
# Spans midnight
if target_dt < start_dt and target_dt < end_dt:
target_dt = target_dt + timedelta(days=1)
end_dt = end_dt + timedelta(days=1)
return start_dt <= target_dt <= end_dt
@@ -0,0 +1,9 @@
purifier_timer:
module: purifier
class: Purifier
on_time: "06:45:00"
off_time: "23:00:00"
entity: fan.xiaomi_miio_device
ha_panel: alarm_control_panel.ha_alarm
global_dependencies:
- globals
@@ -0,0 +1,51 @@
import appdaemon.plugins.hass.hassapi as hass
import globals
#
# Home and away report
# Send report about home state after presence_actions was done.
#
# Args:
# entity - group of entities to send state report
# notify - notify entity to send message
#
# Release Notes
#
# Version 1.0:
# Initial Version
class ReportByPresence(hass.Hass):
report_delay = 15
def initialize(self):
if 'entity' not in self.args or 'notify' not in self.args:
self.error("Please provide entity and notify in config!")
return
self.listen_event(self.away_mode, "away_mode")
self.listen_event(self.return_home_mode, "return_home_mode")
def away_mode(self, event_id, event_args, kwargs):
if 'constraint' in self.args and not self.constrain_input_boolean(self.args['constraint']):
return
#wait 15 seconds before all devices will change thair states.
self.run_in(self.send_report, self.report_delay)
def return_home_mode(self, event_id, event_args, kwargs):
if 'constraint' in self.args and not self.constrain_input_boolean(self.args['constraint']):
return
self.notify("Добро пожаловать домой!", name = self.args['notify'])
############# AWAY REPORT ##########################
def send_report(self, kwargs):
any = False
message = ""
entities = globals.get_group_entities(self, self.args['entity'])
for entity in entities:
state = self.get_state(entity)
if state == "on":
any = True
message += '{} ({}) = {}\n'.format(self.friendly_name(entity), entity, self.get_state(entity))
if any:
message = "В доме остались работать следующие устройства:\n" + message
self.notify(message, name = self.args['notify'])
else:
self.notify("Все устройства в доме выключены.", name = self.args['notify'])
@@ -0,0 +1,10 @@
#################################
# HOME PRESENSE #
#################################
report_action:
module: report_action
class: ReportByPresence
entity: group.report_entities
notify: telegram
global_dependencies:
- globals
@@ -0,0 +1,134 @@
import appdaemon.plugins.hass.hassapi as hass
#
# Tags counter
# This app count 'at home' BLE tags and set home presence state.
# tag names must start from 'tag_'. Room presence sensors will be generated.
#
# Args:
# tag_prefix = tag prefix to search (in my case 'sensor.tag_')
# sensor_prefix = sensor prefix for room presence sensor (ex: if prefix 'sensor.presence_', than sensor name will be 'sensor.presence_hall')
# rooms = rooms list, where tags will be discovered (sensor will be created for each room)
# constraint_prefix (input_boolean) = constraint for tag (ex: input_boolean.tag_monster)
#
# Release Notes
#
# Version 1.0:
# Initial Version
class TagsPresence(hass.Hass):
tags = []
constraint_list = []
tag_by_room_cache = {}
def initialize(self):
if 'tag_prefix' not in self.args or 'sensor_prefix' not in self.args or 'rooms' not in self.args or 'constraint_prefix' not in self.args:
self.error("Please provide tag_prefix, sensor_prefix, constraint_prefix and rooms in config!")
return
self.listen_tags()
# self.listen_event(self.ha_event, "plugin_started")
self.refresh_presence_state()
# def ha_event(self, event_name, data, kwargs):
# self.run_in(self.refresh_presence_state_timer, 10)
def refresh_presence_state_timer(self, kwargs):
self.refresh_presence_state()
def sensor_trigger(self, entity, attribute, old, new, kwargs):
self.refresh_presence_state()
def constraint_trigger(self, entity, attribute, old, new, kwargs):
self.refresh_presence_state()
def listen_tags(self):
self.fill_tags_and_constraints()
for tag in self.tags:
self.listen_state(self.sensor_trigger, tag)
for constraint in self.constraint_list:
self.listen_state(self.constraint_trigger, constraint)
def refresh_presence_state(self):
tags_by_room = self.get_tags_state_by_room(self.tags, self.constraint_list)
tags_by_person = self.get_tags_state_by_person(self.tags, self.constraint_list)
homePresence = 'off'
for room in self.args['rooms']:
sensor = '{}{}'.format(self.args['sensor_prefix'],room)
if room in tags_by_room and len(tags_by_room[room]) > 0:
self.create_sensor(sensor, 'on')
homePresence = 'on'
else:
self.create_sensor(sensor, 'off')
for person in tags_by_person:
if len(tags_by_person[person])>0 and tags_by_person[person] != 'not home':
attr = self.get_state(person, attribute='all').get('attributes', None)
# self.log(attr)
# attr['state'] = tags_by_person[person][0]
self.set_state(person, state = tags_by_person[person][0], attributes = attr)
homeSensor = '{}{}'.format(self.args['sensor_prefix'],'home')
self.create_sensor(homeSensor, homePresence)
def get_tags_state_by_room(self, tags, constraint_list):
tags_by_room = {}
for tag in tags:
constraint = '{}{}'.format(self.args['constraint_prefix'],tag.replace(self.args['tag_prefix'],''))
if constraint in constraint_list and not self.constrain_input_boolean(constraint):
continue
room = self.get_state(tag)
if room not in tags_by_room:
tags_by_room[room] = []
tags_by_room[room].append(tag)
return tags_by_room
def get_tags_state_by_person(self, tags, constraint_list):
tags_by_person = {}
for tag in tags:
constraint = '{}{}'.format(self.args['constraint_prefix'],tag.replace(self.args['tag_prefix'],''))
if constraint in constraint_list and not self.constrain_input_boolean(constraint):
continue
tagName = tag.replace(self.args['tag_prefix'],'')
if '_' in tagName:
tagName = tagName.split('_')[0]
person = '{}{}'.format(self.args['person_prefix'], tagName)
person_state = self.get_state(person)
if person_state != None:
# self.log('{}: {}'.format(person, person_state))
if person not in tags_by_person:
tags_by_person[person] = []
tags_by_person[person].append(self.get_state(tag))
return tags_by_person
# if person_state in self.get_state('person'):
# self.log('{} found'.format(person))
# else:
# self.log('{} not found'.format(person))
# self.log('{}: {}'.format(person, person_state))
# tag_state = self.get_state(tag)
# if room not in tags_by_room:
# tags_by_room[room] = []
# tags_by_room[room].append(tag)
# return tags_by_room
def fill_tags_and_constraints(self):
self.tags = []
self.constraint_list = []
devices = self.get_state()
for tag in devices:
if not tag.startswith(self.args['tag_prefix']):
continue
self.tags.append(tag)
constraint = '{}{}'.format(self.args['constraint_prefix'],tag.replace(self.args['tag_prefix'],''))
if constraint in devices and constraint not in self.constraint_list:
self.constraint_list.append(constraint)
def create_sensor(self, entity_id, state, attributes = None):
current_state = self.get_state(entity_id)
if (current_state == state):
return
# self.log('Updating {} state ({}->{})'.format(entity_id, current_state, state))
if attributes == None:
attributes = {}
attributes['description'] = 'Created and updated from appdaemon (tags_presence)'
self.set_state(entity_id, state = state, attributes = attributes)
@@ -0,0 +1,17 @@
#################################
# HOME PRESENSE #
#################################
tags_presence:
module: tags_presence
class: TagsPresence
tag_prefix: sensor.tag_
sensor_prefix: sensor.presence_
constraint_prefix: input_boolean.tag_
person_prefix: person.
rooms:
- hall
- balcony
- kitchen
- mainroom
global_dependencies:
- globals
@@ -0,0 +1,108 @@
import appdaemon.plugins.hass.hassapi as hass
import datetime
#
# Water control application.
# Controls water valve when all leave and no water needed devices is working.
#
# Args:
# water_valve = water valve entity
# notify - notify entity to send message
# constraint (optional) = input_boolean to enable\disable this automation
#
# Release Notes
#
# Version 1.0:
# Initial Version
class WaterValveControl(hass.Hass):
standby_power_limit = 15
current_wait_cycle = 0
wait_cycles = 3
timer = None
listen_event_handle_list = []
timers = []
def initialize(self):
if 'water_valve' not in self.args or 'water_devices' not in self.args or 'notify' not in self.args:
self.error("Please provide water_valve, water_devices and notify in config!")
return
self.listen_event_handle_list.append(self.listen_event(self.away_mode, "away_mode"))
self.listen_event_handle_list.append(self.listen_event(self.return_home_mode, "return_home_mode"))
self.log(self.get_turned_on_devices())
def return_home_mode(self, event_id, event_args, kwargs):
self.timer = None
if 'constraint' in self.args and not self.constrain_input_boolean(self.args['constraint']):
return
self.turn_on(self.args['water_valve'])
for device in self.args['water_devices']:
self.turn_on(device)
self.cancel_current_timer()
def away_mode(self, event_id, event_args, kwargs):
self.timer = None
if 'constraint' in self.args and not self.constrain_input_boolean(self.args['constraint']):
return
powered_on = self.get_turned_on_devices()
if (len(powered_on) > 0):
device_names = ""
for device in powered_on:
if device_names != '':
device_names += ", "
device_names += self.friendly_name(device)
self.notify('В доме остались требующие воды устройства ({}), подача воды не будет отключена.'.format(device_names), name = self.args['notify'])
self.cancel_current_timer()
self.timers.append(self.run_every(self.wait_when_device_is_done, self.datetime()+datetime.timedelta(seconds=5), 5*60))
else:
self.turn_off(self.args['water_valve'])
for device in self.args['water_devices']:
self.turn_off(device)
def wait_when_device_is_done(self, kwargs):
if 'constraint' in self.args and not self.constrain_input_boolean(self.args['constraint']):
return
powered_on = self.get_turned_on_devices()
self.log('still powered on: {}'.format(powered_on))
if len(powered_on) == 1:
self.current_wait_cycle = 0
elif len(powered_on) == 0 and self.current_wait_cycle<self.wait_cycles:
self.log("Current wait cycle: {}".format(self.current_wait_cycle))
self.current_wait_cycle = self.current_wait_cycle+1
elif len(powered_on) == 0 and self.current_wait_cycle>=self.wait_cycles:
self.current_wait_cycle = 0
self.notify('Устройства требующие воды закончили свою работу, отключаю подачу воды.', name = self.args['notify'])
self.turn_off_water_devices()
self.cancel_current_timer()
def turn_off_water_devices():
self.log("Turning off water devices.")
self.turn_off(self.args['water_valve'])
for device in self.args['water_devices']:
self.turn_off(device)
def get_turned_on_devices(self):
powered_on_devices = []
devices = self.args['water_devices']
for device in devices:
state = self.get_state(device)
attributes = self.get_state(device, attribute = "all")
if attributes == None:
continue
if 'attributes' in attributes:
attributes = attributes['attributes']
if 'load_power' not in attributes:
continue
load_power = attributes['load_power']
self.log('{} state: {}, load power is: {}'.format(device, state, load_power))
if state == 'off' or load_power < self.standby_power_limit:
continue
powered_on_devices.append(device)
return powered_on_devices
def cancel_current_timer(self):
for timer in self.timers:
self.log('canceling timer')
self.cancel_timer(timer)
self.timers = []
@@ -0,0 +1,14 @@
#################################
# HOME PRESENSE #
#################################
water_action:
module: water_action
class: WaterValveControl
water_valve: switch.water_valve
notify: telegram
constraint: input_boolean.water_control
water_devices:
- switch.plug_158d0001a68c64 # Посудомойка
- switch.plug_158d0001a4cfb4 # стиралка
global_dependencies:
- globals
@@ -0,0 +1,63 @@
import appdaemon.plugins.hass.hassapi as hass
#
# Water leak detector
# close water valve and send a report
#
# Args:
#
# sensor = water leak sensor group
# valve = water valve - app will close it, when leak is detected
# notify = notify name to send notification
#
# None
#
# Release Notes
#
# Version 1.0:
# Initial Version
class WaterLeak(hass.Hass):
ringtone = 2
gw_mac = ''
sensors = []
def initialize(self):
if 'sensor' not in self.args or 'valve' not in self.args or 'notify' not in self.args:
self.error("Please provide sensor, valve and notify in config!")
return
self.sensors = self.get_water_leak_sensors()
self.log("leak sensors: {}".format(self.sensors))
for sensor in self.sensors:
self.listen_state(self.water_leak, sensor)
def get_water_leak_sensors(self):
sensors = []
devices = self.get_state()
for sensor in devices:
if not sensor.startswith(self.args['sensor_prefix']):
continue
sensors.append(sensor)
return sensors
def water_leak(self, entity, attribute, old, new, kwargs):
if new == "on":
self.send_alarm()
def send_alarm(self):
self.log('water leak alarm triggered!')
if 'constraint' in self.args and not self.constrain_input_boolean(self.args['constraint']):
return
#close water valve
self.turn_off(self.args['valve'])
## to ensure that water is closed. It is important!!!
self.call_service("mqtt/publish", topic = 'home/watercontrol/bath/valve/set', payload = 'close')
self.call_service("xiaomi_aqara/play_ringtone", gw_mac=self.gw_mac, ringtone_id=self.ringtone, ringtone_vol=100)
message = "ВНИМАНИЕ! В ваше отстутствие сработал датчик протечки воды!!! Выключаю подачу воды."
self.notify(message, name = self.args['notify'])
message = "Состояние датчиков протечки:\n"
for entity_id in self.sensors:
message += '{} ({}) = {}\n'.format(self.friendly_name(entity_id), entity_id, self.get_state(entity_id))
self.notify(message, name = self.args['notify'])
@@ -0,0 +1,14 @@
#################################
# WATER LEAK #
#################################
water_leak:
module: water_leak
class: WaterLeak
constraint: input_boolean.water_leak_control
sensor: group.water_leak_sensors
valve: switch.water_valve
sensor_prefix: binary_sensor.water_leak_
notify: telegram
gw_mac: !secret aqara1_mac
global_dependencies:
- globals
+365
View File
@@ -0,0 +1,365 @@
import sys
sys.path.insert(0, '/emp_mos_api')
import globals
import datetime as dt
from automation import Automation, Base
from datetime import datetime, tzinfo, timedelta
from emp_mos_api.mos import MosAPI, Watercounter, Water, EmpServerException
#from mos_lib import MosAPI, Watercounter, Water, EmpServerException
class WaterCounterSender(Automation):
timezone = '+03:00'
def initialize(self):
super().initialize()
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()
water_hot = self.app.water_hot
water_cold = self.app.water_cold
counters = [water_hot, water_cold]
for counter in counters:
counter_type = counter['type']
checkup = counter['checkup'].replace(self.timezone,'')
checkup_date = datetime.strptime(checkup, '%Y-%m-%d')
# подходит дата поверки
if checkup_date < datetime.now():
self.error('Counter {} checkup date is expired at {}!'.format(counter['counterId'], checkup))
continue
our_sensor = ""
if counter_type == 1:
our_sensor = self.entity_ids['cold']
elif counter_type == 2:
our_sensor = self.entity_ids['hot']
real_value = int(float(self.get_state(our_sensor)))
self.log("Updating counter {} ({}) to real value: {}".format(counter['counterId'], our_sensor, real_value))
new_values.append(Watercounter.serialize_for_send(counter, real_value))
if new_values:
try:
self.app.send_watercounters(new_values)
self.log('Counters values was sent to mos.ru')
except EmpServerException as err:
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')
if 'threshold' in self.args and 'power_mosenergosbyt_total' in self.args:
power_mosenergosbyt_total = float(self.get_state(self.args['power_mosenergosbyt_total']))
threshold = self.args['threshold']
diff = power_total-power_mosenergosbyt_total
if diff<threshold:
self.log('power_total: {}, power_mosenergosbyt_total: {}, diff: {}, threshold: {}'.format(power_total, power_mosenergosbyt_total, diff, threshold))
self.log('skipping send')
return
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()+timedelta(seconds=10), 1*60*60)
def update_sensors(self, args) -> None:
self.log('Updating water counters')
self.app.update_watercounters()
if 'hot' in self.entity_ids:
water_hot = self.app.water_hot
self.create_water_entity(self.entity_ids['hot'], water_hot)
if 'cold' in self.entity_ids:
water_cold = self.app.water_cold
self.create_water_entity(self.entity_ids['cold'], water_cold)
def create_water_entity(self, entity_id, counter):
indication_max = 0
indication_date = ""
for indication in counter['indications']:
current = float(indication['indication'])
if (current > indication_max):
indication_max = current
indication_date = indication['period']
indication_date = indication_date.replace(self.timezone,'')
counter_type = counter['type']
checkup = counter['checkup']
type_desc = ""
type_desc_ru = ""
if counter_type == 1:
type_desc = "cold"
type_desc_ru = "Холодная"
elif counter_type == 2:
type_desc = "hot"
type_desc_ru = "Горячая"
icon = "mdi:av-timer"
checkup = checkup.replace(self.timezone,'')
checkup_date = datetime.strptime(checkup, '%Y-%m-%d')
# подходит дата поверки
if (checkup_date - timedelta(days=31)) < datetime.now():
icon = "mdi:alert-octagon"
attributes = {}
attributes['unit_of_measurement'] = 'm3'
attributes['date'] = indication_date
attributes['checkup'] = checkup
attributes['icon'] = icon
attributes['friendly_name'] = "{}(mos.ru)".format(type_desc_ru)
attributes['description'] = 'Created and updated from appdaemon ({})'.format(__name__)
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()
self.log('power data: {}'.format(self.app._power_counters))
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()
self.timer = self.run_every(self.update_sensors, self.datetime()+timedelta(seconds=40), 1*60*60)
def update_sensors(self, args) -> None:
if 'epd_balance' in self.entity_ids:
self.app.update_epd()
epd_balance = self.app.epd_balance
epd_description = self.app.epd_description
self.log('Current epd balance: {}'.format(epd_balance))
entity_id = self.entity_ids['epd_balance']
attributes = {
'unit_of_measurement': '₽',
'icon': 'mdi:bank-transfer',
'date': datetime.now().strftime('%d.%m.%Y'),
'unpaid': epd_description,
'friendly_name': 'Баланс (ЕПД)',
'description': 'Created and updated from appdaemon ({})'.format(__name__)
}
self.set_state(entity_id, state = epd_balance, attributes = attributes)
class MosruBase(Base):
_api = None
_flat_id = None
_guid='4421a3fb0567714b27dec84d3c3862dc'
_app_version = '3.5.0.195 (102)'
_user_agent = 'okhttp/3.8.1'
_dev_user_agent = 'Android'
_timezone = '+03:00'
def initialize(self):
super().initialize()
if not globals.check_properties(self,['login', 'pwd', 'token', 'paycode', 'session_id']):
return
self._api = MosAPI(token=self.properties['token'],
user_agent=self._user_agent,
guid=self._guid,
dev_user_agent=self._dev_user_agent,
dev_app_version=self._app_version,
timeout=6)
self._set_flat_id()
def _set_flat_id(self):
''' Set flat id by properties or pay code '''
if 'flat_id' in self.properties:
self._flat_id = self.properties['flat_id']
return
if (self._flat_id != None):
return
self._check_login()
flats = self._api.get_flats()
for flat in flats:
if (int(flat['paycode']) != self.properties['paycode']):
continue
self._flat_id = flat['flat_id']
def _check_login(self):
self._api.set_session(self.properties['session_id'])
# if not self._api.is_active():
# self._api.login(self.properties['login'], self.properties['pwd'])
class MosruPower(MosruBase):
_power_counters = None
@property
def power_total(self):
if (self._power_counters == None):
self.update_power()
return self._power_counters['zones'][0]['value']
@property
def power_balance(self):
if (self._power_counters == None):
self.update_power()
return float(self._power_counters['balance'])
def update_power(self) -> None:
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
@property
def water_hot(self):
"""Return the current state of counter."""
if (self._water_counters == None):
self.update_watercounters()
hots_json = list(filter(lambda x: x['type'] == Water.HOT, self._water_counters))
return hots_json[0]
@property
def water_cold(self):
"""Return the current state of counter."""
if (self._water_counters == None):
self.update_watercounters()
colds_json = list(filter(lambda x: x['type'] == Water.COLD, self._water_counters))
return colds_json[0]
@property
def water_hot_value(self):
hot_value = Watercounter.last_value(self.water_hot)
return hot_value
@property
def water_cold_value(self):
cold_value = Watercounter.last_value(self.water_cold)
return cold_value
def send_watercounters(self, new_values) -> None:
self._check_login()
self._api.send_watercounters(self._flat_id, new_values)
def update_watercounters(self) -> None:
self._check_login()
self._water_counters = self._api.get_watercounters(self._flat_id)['counters']
class MosruEpd(MosruBase):
_epd_data = None
@property
def epd_description(self) -> str:
if (self._epd_data == None):
self.update_epd()
unpaid = []
for date in self._epd_data:
epd = self._epd_data[date]
if epd['payment_status'] == 'PAID':
continue
unpaid.append('{}: {}'.format(date, epd['amount']))
return ", ".join(unpaid)
@property
def epd_balance(self) -> float:
"""Return the current state of counter."""
if (self._epd_data == None):
self.update_epd()
balance = 0
for date in self._epd_data:
epd = self._epd_data[date]
epd_is_paid = epd['payment_status'] == 'PAID'
if epd_is_paid:
continue
balance = balance - float(epd['amount'].replace(',','.'))
return balance
def update_epd(self) -> None:
self._check_login()
data = {}
dateEnd = datetime.now().date()
dateStart = dateEnd - timedelta(6*365/12)
date_start = dateStart.strftime('%d.%m.%Y')
date_end = dateEnd.strftime('%d.%m.%Y')
epds = self._api.get_epd(self._flat_id, date_start, date_end)
for i in range(0, 3): #retries
if epds != []:
break
epds = self._api.get_epd(self._flat_id, date_start, date_end)
if len(epds) == 0:
self.log('epd not found for date: {}'.format(date_str))
return
for epd in epds:
date_str = epd['period']
data[date_str] = epd
# epd_total = epd_first['amount']
# epd_is_paid = epd_first['is_paid']
# self.log(" - Дата: {}, сумма: {}, оплачен: {}.".format(date_str, epd_total, epd_is_paid))
self._epd_data = data
class MosruClient(MosruEpd, MosruWater, MosruPower):
def initialize(self):
super().initialize()
self.log('Flat id is: {}'.format(self._flat_id))
# self.log('epd balance: {}'.format(self.epd_balance))
# self.log('cold: {}'.format(self.water_cold))
# self.log('hot: {}'.format(self.water_hot))
# self.log('power_counter: {}, balance: {}'.format(self.power_total, self.power_balance))
+127
View File
@@ -0,0 +1,127 @@
#################################
# MOS.RU DATA RETRIEVER #
#################################
mosru_client:
class: MosruClient
module: mosru
properties:
login: !secret mosru_login
pwd: !secret mosru_pwd
token: !secret mosru_token
paycode: !secret mosru_paycode
flat_id: !secret mosru_flat_id
session_id: !secret mosru_session
global_dependencies:
- globals
epd_sensor:
class: EpdBalanceSensor
module: mosru
entity_ids:
epd_balance: sensor.epd_balance
app: mosru_client
dependencies:
- mosru_client
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
entity_ids:
hot: sensor.counter_mosru_hot
cold: sensor.counter_mosru_cold
app: mosru_client
dependencies:
- mosru_client
global_dependencies:
- globals
water_sender:
class: WaterCounterSender
module: mosru
entity_ids:
hot: sensor.counter_hot
cold: sensor.counter_cold
app: mosru_client
dependencies:
- mosru_client
global_dependencies:
- globals
power_sender:
class: PowerCounterSender
module: mosru
power_mosenergosbyt_total: sensor.power_mosenergosbyt_total
threshold: 100
entity_ids:
power_total: sensor.power_main_total
app: mosru_client
dependencies:
- mosru_client
global_dependencies:
- globals
#---------------------------------------------------------
mosru_client_belorechenskaya:
class: MosruClient
module: mosru
properties:
login: !secret mosru_login
pwd: !secret mosru_pwd
token: !secret mosru_token
paycode: !secret mosru_belorechenskaya_paycode
flat_id: !secret mosru_belorechenskaya_flat_id
session_id: !secret mosru_session
global_dependencies:
- globals
epd_sensor_belorechenskaya:
class: EpdBalanceSensor
module: mosru
entity_ids:
epd_balance: sensor.epd_balance_belorechenskaya
app: mosru_client_belorechenskaya
dependencies:
- mosru_client_belorechenskaya
global_dependencies:
- globals
power_sensor_belorechenskaya:
class: PowerCounterSensor
module: mosru
entity_ids:
power_balance: sensor.power_mosenergosbyt_belorechenskaya_balance
power_total: sensor.power_mosenergosbyt_belorechenskaya_total
app: mosru_client_belorechenskaya
dependencies:
- mosru_client_belorechenskaya
global_dependencies:
- globals
water_sensor_belorechenskaya:
class: WaterCounterSensor
module: mosru
entity_ids:
hot: sensor.counter_mosru_belorechenskaya_hot
cold: sensor.counter_mosru_belorechenskaya_cold
app: mosru_client_belorechenskaya
dependencies:
- mosru_client_belorechenskaya
global_dependencies:
- globals
@@ -0,0 +1,30 @@
import appdaemon.plugins.hass.hassapi as hass
# 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 Monitor(hass.Hass):
def initialize(self) -> None:
# super().initialize()
self.listen_event_handle_list = []
events = self.args['events']
if events != None:
for event in self.split_device_list(self.args["events"]):
self.log('watching event "{}" for state changes'.format(event))
self.listen_event_handle_list.append(self.listen_event(self.changed, event))
if len(self.listen_event_handle_list) == 0:
self.log('watching all events for state changes')
self.listen_event_handle_list.append(self.listen_event(self.changed))
def changed(self, event_name, data, kwargs):
self.log(event_name + ': ' + str(data))
def terminate(self):
for listen_event_handle in self.listen_event_handle_list:
self.cancel_listen_event(listen_event_handle)
@@ -0,0 +1,6 @@
# eventMonitor:
# module: eventMonitor
# class: Monitor
# global_dependencies:
# - automation
# events:
@@ -0,0 +1,67 @@
import appdaemon.plugins.hass.hassapi as hass
import appdaemon.plugins.mqtt.mqttapi as mqtt
import globals
import json
import random
import time
import datetime
class HappyBubblesToDeviceTracker(mqtt.Mqtt, hass.Hass):
base_topic = "happy-bubbles/presence/ha"
tag_timers = {}
def initialize(self) -> None:
self.log('Initialize HappyBubblesToDeviceTracker')
self.mqtt_subscribe('{}/#'.format(self.base_topic), namespace='mqtt')
self.listen_event(self.event_listener, 'MQTT_MESSAGE', namespace='mqtt')
def event_listener(self, event_name, data, kwargs):
topic = data.get('topic', '')
if self.base_topic not in topic:
return
location = topic.replace('{}/'.format(self.base_topic), "")
payload = json.loads(data['payload']) if data.get('payload') else {}
# self.log('[{}] topic: {}, payload: {}'.format(event_name, topic, payload))
id = payload.get('id', None)
name = payload.get('name', None)
distance = payload.get('distance', None)
# devices = self.get_state()
# device_name = "device_tracker.tag_ble_{}".format(id)
# if device_name in devices:
# self.log('tag found in devices')
self.move_tag(id, isHome=True)
self.restart_timer(id)
self.log('Beacon {} ({}) moved to {} (distance: {}).'.format(name, id, location, distance))
def restart_timer(self, id):
if id in self.tag_timers:
self.cancel_timer(self.tag_timers[id])
self.tag_timers[id] = self.run_in(self.tag_away_timer_callback, 60, id=id)
def tag_away_timer_callback(self, kwargs):
id = kwargs.get('id', None)
if id == None:
self.log('id is forgotten')
return
self.log('awaying tag:{}'.format(id))
self.move_tag(id, isHome=False)
def move_tag(self, id, isHome = False):
hb_topic = "owntracks/tag/ble_{}".format(id)
tst = int(round(time.time(),0))
payload = '{"_type":"location","acc":18,"alt":0,"batt":100,"lat":55.7899036,"lon":37.6595347,"tst":1579857481}'
jsobj = json.loads(payload)
jsobj['tst'] = tst
if isHome:
jsobj['lat'] = self.args['home_lat']
jsobj['lon'] = self.args['home_lon']
else:
jsobj['lat'] = 0
jsobj['lon'] = 0
payload = json.dumps(jsobj)
self.mqtt_publish(hb_topic, payload, qos = 0, retain = False, namespace='mqtt')
@@ -0,0 +1,8 @@
# happyBubblesToDeviceTracker:
# module: happyBubblesToDeviceTracker
# class: HappyBubblesToDeviceTracker
# global_dependencies:
# - globals
# constrain: input_boolean.constraint_test
# home_lat: !secret home_latitude
# home_lon: !secret home_longitude
@@ -0,0 +1,37 @@
import appdaemon.plugins.hass.hassapi as hass
import appdaemon.plugins.mqtt.mqttapi as mqtt
import globals
import json
import random
"""
Monitor events and output changes to the verbose_log. Nice for debugging purposes.
Arguments:
- events: List of events to monitor
"""
class RassToHappyBubbles(mqtt.Mqtt):
base_topic = "home/presence"
def initialize(self) -> None:
self.log('Initialize RassToHappyBubbles')
self.mqtt_subscribe('{}/#'.format(self.base_topic), namespace='mqtt')
self.listen_event(self.event_listener, 'MQTT_MESSAGE', namespace='mqtt')
def event_listener(self, event_name, data, kwargs):
topic = data.get('topic', '')
if self.base_topic not in topic:
return
#array = ['hall', 'balcony']
#random.shuffle(array)
#device=array[0]
device = topic.replace('{}/'.format(self.base_topic), "")
payload = json.loads(data['payload']) if data.get('payload') else {}
id = payload.get('id', None)
hb_topic="happy-bubbles/ble/{}/raw/{}".format(device,id)
payload['hostname'] = device
payload['mac'] = payload.get('id', '')
payload_data = json.dumps(payload)
self.mqtt_publish(hb_topic, payload_data, qos = 0, retain = False, namespace='mqtt')
# self.log('publishing to: {}: {}'.format(hb_topic, payload_data))
@@ -0,0 +1,6 @@
# rassToHappybubbles:
# module: rassToHappybubbles
# class: RassToHappyBubbles
# global_dependencies:
# - globals
# constrain: input_boolean.constraint_test
+20
View File
@@ -0,0 +1,20 @@
import appdaemon.plugins.hass.hassapi as hass
import os
"""
Monitor events and output changes to the verbose_log. Nice for debugging purposes.
Arguments:
- events: List of events to monitor
"""
class Test(hass.Hass):
notify = None
def initialize(self) -> None:
self.listen_state(self.sensor_trigger, 'sensor.0x00158d0003a361d8_action')
self.listen_state(self.sensor_trigger, 'binary_sensor.0x00158d0003f26d61_occupancy')
# self.listen_state(self.sensor_trigger, 'sensor.remote20_action')
def sensor_trigger(self, entity, attribute, old, new, kwargs):
if new != "" and new != "unknown":
self.log("doorbell call")
self.toggle("switch.0x00158d00020f3a29_switch")
+5
View File
@@ -0,0 +1,5 @@
test:
module: test
class: Test
dependencies:
- notify
+14
View File
@@ -0,0 +1,14 @@
#
# Main arguments, all optional
#
title: Hello Panel
widget_dimensions: [120, 120]
widget_margins: [5, 5]
columns: 8
label:
widget_type: label
text: Hello World
layout:
- label(2x2)
View File
+7
View File
@@ -0,0 +1,7 @@
FROM certbot/certbot:latest
ADD ./update_certs.sh /update_certs.sh
RUN chmod +x /update_certs.sh
CMD ""
ENTRYPOINT "/update_certs.sh"
+7
View File
@@ -0,0 +1,7 @@
FROM certbot/certbot:arm32v6-latest
ADD ./update_certs.sh /update_certs.sh
RUN chmod +x /update_certs.sh
CMD ""
ENTRYPOINT "/update_certs.sh"
+6
View File
@@ -0,0 +1,6 @@
#!/bin/sh
while [ 1 ];
do
certbot renew --agree-tos --standalone
sleep 1d
done
+59
View File
@@ -0,0 +1,59 @@
version: '3'
services:
mosquitto:
build: ./mosquitto/docker
container_name: mosquitto
env_file: ./mosquitto/settings/mqtt.env
ports:
- "1883:1883"
restart: always
logging:
driver: json-file
options:
max-size: 50m
hass:
image: ghcr.io/home-assistant/home-assistant:stable
container_name: hass
volumes:
- ./hass/settings:/config
- /run/dbus:/run/dbus:ro
- /etc/localtime:/etc/localtime:ro
ports:
- "8123:8123"
depends_on:
- mosquitto
#network_mode: "host"
#privileged: true
restart: always
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8123"]
interval: 30s
timeout: 10s
retries: 6
logging:
driver: json-file
options:
max-size: 50m
zigbee2mqtt:
image: koenkk/zigbee2mqtt:latest-dev
container_name: zigbee2mqtt
ports:
- "8080:8080"
volumes:
- ./zigbee2mqtt/settings:/app/data
devices:
- "/dev/ttyS2:/dev/ttyS2"
depends_on:
- mosquitto
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8080"]
interval: 30s
timeout: 10s
retries: 6
restart: always
logging:
driver: json-file
options:
max-size: 50m
+164
View File
@@ -0,0 +1,164 @@
version: '3'
services:
mosquitto:
build: ./mosquitto/docker
container_name: mosquitto
env_file: ./mosquitto/settings/mqtt.env
ports:
- "1883:1883"
restart: always
logging:
driver: json-file
options:
max-size: 50m
hass:
image: homeassistant/home-assistant:latest
container_name: hass
volumes:
- ./hass/settings:/config
- ./secrets/hass-alt/settings/secrets.yaml:/config/secrets.yaml:ro
- /etc/localtime:/etc/localtime:ro
ports:
- "8123:8123"
- "9898:9898/udp"
- "4321:4321/udp"
- "51827:51827" #Homekit
devices:
- "/dev/ttyUSB0:/dev/ttyUSB0"
# - "/dev/ttyACM0:/dev/ttyACM0"
depends_on:
- mosquitto
- tor
#network_mode: "host"
#privileged: true
restart: always
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8123"]
interval: 30s
timeout: 10s
retries: 6
logging:
driver: json-file
options:
max-size: 50m
appdaemon:
build:
context: ./appdaemon/docker
container_name: appdaemon
depends_on:
- hass
volumes:
- ./appdaemon/settings:/conf
- ./secrets/hass-alt/settings/secrets.yaml:/conf/secrets.yaml:ro
- /etc/localtime:/etc/localtime:ro
restart: always
links:
- "hass"
logging:
driver: json-file
options:
max-size: "10m"
max-file: "5"
esphome:
image: esphome/esphome #esphome-armv7
container_name: esphome
ports:
- "6052:6052"
volumes:
- ./esphome/settings:/config
- ./secrets/hass-alt/settings/secrets.yaml:/config/secrets.yaml:ro
network_mode: "host"
logging:
driver: json-file
options:
max-size: "10m"
max-file: "5"
zigbee2mqtt:
image: koenkk/zigbee2mqtt:latest-dev
container_name: zigbee2mqtt
ports:
- "8080:8080"
volumes:
- ./zigbee2mqtt/settings:/app/data
devices:
- "/dev/ttyS2:/dev/ttyS2"
#devices:
# - /dev/ttyUSB1:/dev/ttyUSB1
privileged: true
restart: always
logging:
driver: json-file
options:
max-size: 50m
nginx:
image: nginx
hostname: nginx
container_name: nginx
links:
- "hass"
# - "appdaemon"
- "certbot"
volumes:
- ./nginx/settings/site-confs:/etc/nginx/conf.d
- ./nginx/settings/www:/var/www
- ./certbot/settings/data:/config/keys
ports:
- "80:80"
- "443:443"
restart: always
logging:
driver: json-file
options:
max-size: "10m"
max-file: "5"
certbot:
build:
context: ./certbot/docker
dockerfile: Dockerfile #Dockerfile-rpi
restart: always
container_name: certbot
volumes:
- ./certbot/docker/update_certs.sh:/update_certs.sh:ro
- ./certbot/settings/data:/etc/letsencrypt
- /etc/localtime:/etc/localtime:ro
ports:
- "8081:80"
#entrypoint: certbot
#command: certonly --standalone --agree-tos --non-interactive --email admin@yandex5.ru -d sibur.yandex5.ru
logging:
driver: json-file
options:
max-size: "10m"
max-file: "5"
neptun2mqtt:
build:
context: ./neptun2mqtt/docker
dockerfile: Dockerfile
restart: always
container_name: neptun2mqtt
environment:
- MQTT_SERVER=192.168.1.6
- MQTT_USER=hass
- MQTT_PASS=mqttpass
# - NEPTUN_IP="192.168.1.147"
logging:
driver: json-file
options:
max-size: "10m"
max-file: "5"
tor:
build:
context: ./tor/docker
container_name: tor
ports:
- "9150:9150"
- "9051:9051" #control port
restart: always
+131
View File
@@ -0,0 +1,131 @@
- id: '1606065844556'
alias: New Automation
description: ''
trigger:
- platform: device
domain: mqtt
device_id: 313bedaded5359768194cb385454fa12
type: action
subtype: button_1_single
discovery_id: 0x00124b001f835180 action_button_1_single
condition: []
action:
- type: toggle
device_id: 3c506f1e2cce50e12b6cbe9aa32769b3
entity_id: switch.0x00158d00020f3a29_switch
domain: switch
mode: single
- id: '1606065870254'
alias: New Automation
description: ''
trigger:
- platform: device
domain: mqtt
device_id: 313bedaded5359768194cb385454fa12
type: action
subtype: button_1_single
discovery_id: 0x00124b001f835180 action_button_1_single
condition: []
action:
- type: toggle
device_id: 3c506f1e2cce50e12b6cbe9aa32769b3
entity_id: switch.0x00158d00020f3a29_switch
domain: switch
mode: single
- id: '1652987572136'
alias: Test_z2m_tcp
description: ''
trigger:
- platform: device
domain: mqtt
device_id: 7175406f0df38fcc9c6c3a80d7a5b408
type: action
subtype: single
discovery_id: 0x00158d000373d5ad action_single
condition: []
action:
- type: toggle
device_id: f1d92728626dad97fe3c4361b8c6b848
entity_id: switch.z2m_light
domain: switch
mode: single
- id: '1652987617301'
alias: TestZ2MTcp
description: Привет
trigger:
- platform: device
domain: mqtt
device_id: 7175406f0df38fcc9c6c3a80d7a5b408
type: action
subtype: single
discovery_id: 0x00158d000373d5ad action_single
condition: []
action:
- type: toggle
device_id: f1d92728626dad97fe3c4361b8c6b848
entity_id: switch.z2m_light
domain: switch
mode: single
- id: '1652987881821'
alias: New Automation
description: ''
trigger:
- platform: device
domain: mqtt
device_id: 7175406f0df38fcc9c6c3a80d7a5b408
type: action
subtype: single
discovery_id: 0x00158d000373d5ad action_single
condition: []
action:
- type: toggle
device_id: f1d92728626dad97fe3c4361b8c6b848
entity_id: switch.z2m_light
domain: switch
mode: single
- id: '1652988466792'
alias: New Automation
description: ''
trigger:
- platform: device
domain: mqtt
device_id: 7175406f0df38fcc9c6c3a80d7a5b408
type: action
subtype: single
discovery_id: 0x00158d000373d5ad action_single
condition: []
action:
- type: toggle
device_id: f1d92728626dad97fe3c4361b8c6b848
entity_id: switch.z2m_light
domain: switch
mode: single
- id: '1669579519642'
alias: New Automation222222
description: ''
trigger:
- platform: device
type: changed_states
device_id: 151cd6eb3b45340baa2e3ef234ebe3e9
entity_id: light.fnvdoorbell_indicator_light
domain: light
condition: []
action:
- service: alarm_control_panel.alarm_arm_away
data: {}
mode: single
- id: '1669579755275'
alias: SendTelegramOnCall
description: ''
trigger:
- platform: device
device_id: 151cd6eb3b45340baa2e3ef234ebe3e9
domain: select
entity_id: select.fnvdoorbell_record_mode
type: current_option_changed
condition: []
action:
- service: notify.telegram_monster
data:
message: test
mode: single
@@ -0,0 +1,56 @@
blueprint:
name: Motion-activated Light
description: Turn on a light when motion is detected.
domain: automation
source_url: https://github.com/home-assistant/core/blob/dev/homeassistant/components/automation/blueprints/motion_light.yaml
author: Home Assistant
input:
motion_entity:
name: Motion Sensor
selector:
entity:
filter:
device_class: motion
domain: binary_sensor
light_target:
name: Light
selector:
target:
entity:
domain: light
no_motion_wait:
name: Wait time
description: Time to leave the light on after last motion is detected.
default: 120
selector:
number:
min: 0
max: 3600
unit_of_measurement: seconds
# If motion is detected within the delay,
# we restart the script.
mode: restart
max_exceeded: silent
trigger:
platform: state
entity_id: !input motion_entity
from: "off"
to: "on"
action:
- alias: "Turn on the light"
service: light.turn_on
target: !input light_target
- alias: "Wait until there is no motion from device"
wait_for_trigger:
platform: state
entity_id: !input motion_entity
from: "on"
to: "off"
- alias: "Wait the number of seconds that has been set"
delay: !input no_motion_wait
- alias: "Turn off the light"
service: light.turn_off
target: !input light_target
@@ -0,0 +1,50 @@
blueprint:
name: Zone Notification
description: Send a notification to a device when a person leaves a specific zone.
domain: automation
source_url: https://github.com/home-assistant/core/blob/dev/homeassistant/components/automation/blueprints/notify_leaving_zone.yaml
author: Home Assistant
input:
person_entity:
name: Person
selector:
entity:
filter:
domain: person
zone_entity:
name: Zone
selector:
entity:
filter:
domain: zone
notify_device:
name: Device to notify
description: Device needs to run the official Home Assistant app to receive notifications.
selector:
device:
filter:
integration: mobile_app
trigger:
platform: state
entity_id: !input person_entity
variables:
zone_entity: !input zone_entity
# This is the state of the person when it's in this zone.
zone_state: "{{ states[zone_entity].name }}"
person_entity: !input person_entity
person_name: "{{ states[person_entity].name }}"
condition:
condition: template
# The first case handles leaving the Home zone which has a special state when zoning called 'home'.
# The second case handles leaving all other zones.
value_template: "{{ zone_entity == 'zone.home' and trigger.from_state.state == 'home' and trigger.to_state.state != 'home' or trigger.from_state.state == zone_state and trigger.to_state.state != zone_state }}"
action:
- alias: "Notify that a person has left the zone"
domain: mobile_app
type: notify
device_id: !input notify_device
message: "{{ person_name }} has left {{ zone_state }}"
@@ -0,0 +1,86 @@
blueprint:
name: Confirmable Notification
description: >-
A script that sends an actionable notification with a confirmation before
running the specified action.
domain: script
source_url: https://github.com/home-assistant/core/blob/master/homeassistant/components/script/blueprints/confirmable_notification.yaml
author: Home Assistant
input:
notify_device:
name: Device to notify
description: Device needs to run the official Home Assistant app to receive notifications.
selector:
device:
filter:
integration: mobile_app
title:
name: "Title"
description: "The title of the button shown in the notification."
default: ""
selector:
text:
message:
name: "Message"
description: "The message body"
selector:
text:
confirm_text:
name: "Confirmation Text"
description: "Text to show on the confirmation button"
default: "Confirm"
selector:
text:
confirm_action:
name: "Confirmation Action"
description: "Action to run when notification is confirmed"
default: []
selector:
action:
dismiss_text:
name: "Dismiss Text"
description: "Text to show on the dismiss button"
default: "Dismiss"
selector:
text:
dismiss_action:
name: "Dismiss Action"
description: "Action to run when notification is dismissed"
default: []
selector:
action:
mode: restart
sequence:
- alias: "Set up variables"
variables:
action_confirm: "{{ 'CONFIRM_' ~ context.id }}"
action_dismiss: "{{ 'DISMISS_' ~ context.id }}"
- alias: "Send notification"
domain: mobile_app
type: notify
device_id: !input notify_device
title: !input title
message: !input message
data:
actions:
- action: "{{ action_confirm }}"
title: !input confirm_text
- action: "{{ action_dismiss }}"
title: !input dismiss_text
- alias: "Awaiting response"
wait_for_trigger:
- platform: event
event_type: mobile_app_notification_action
event_data:
action: "{{ action_confirm }}"
- platform: event
event_type: mobile_app_notification_action
event_data:
action: "{{ action_dismiss }}"
- choose:
- conditions: "{{ wait.trigger.event.data.action == action_confirm }}"
sequence: !input confirm_action
- conditions: "{{ wait.trigger.event.data.action == action_dismiss }}"
sequence: !input dismiss_action
+46
View File
@@ -0,0 +1,46 @@
homeassistant:
name: Home
latitude: !secret home_latitude
longitude: !secret home_longitude
elevation: 124
unit_system: metric
time_zone: !secret timezone
packages: !include_dir_named packages
logger:
default: info
logs:
custom_components.yandex_smart_home: debug
icloud: debug
api:
http:
base_url: !secret base_url
use_x_forwarded_for: true
ip_ban_enabled: true
login_attempts_threshold: 5
trusted_proxies:
- 172.19.0.0/24
- 172.18.0.0/24
mobile_app:
# Enables the frontend
frontend:
lovelace:
mode: yaml
# Enables configuration UI
config:
discovery:
system_health:
my:
# # Enables support for tracking state changes over time
history:
logbook:
home_connect_alt:
client_id: 1F4C88E5BCEF91574CA74AE15A330C64C81C43196F98482F7FEBF03F6073C86A
client_secret: 7C9EFEFB6A4492EABAFA2B25D9378376A1AF397B830E78133F1DCB2D602BC8BA
@@ -0,0 +1,263 @@
"""
HACS gives you a powerful UI to handle downloads of all your custom needs.
For more details about this integration, please refer to the documentation at
https://hacs.xyz/
"""
from __future__ import annotations
import os
from typing import Any
from aiogithubapi import AIOGitHubAPIException, GitHub, GitHubAPI
from aiogithubapi.const import ACCEPT_HEADERS
from awesomeversion import AwesomeVersion
from homeassistant.components.lovelace.system_health import system_health_info
from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
from homeassistant.const import Platform, __version__ as HAVERSION
from homeassistant.core import HomeAssistant
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.discovery import async_load_platform
from homeassistant.helpers.event import async_call_later
from homeassistant.helpers.start import async_at_start
from homeassistant.loader import async_get_integration
import voluptuous as vol
from .base import HacsBase
from .const import DOMAIN, MINIMUM_HA_VERSION, STARTUP
from .enums import ConfigurationType, HacsDisabledReason, HacsStage, LovelaceMode
from .frontend import async_register_frontend
from .utils.configuration_schema import hacs_config_combined
from .utils.data import HacsData
from .utils.queue_manager import QueueManager
from .utils.version import version_left_higher_or_equal_then_right
from .websocket import async_register_websocket_commands
CONFIG_SCHEMA = vol.Schema({DOMAIN: hacs_config_combined()}, extra=vol.ALLOW_EXTRA)
async def async_initialize_integration(
hass: HomeAssistant,
*,
config_entry: ConfigEntry | None = None,
config: dict[str, Any] | None = None,
) -> bool:
"""Initialize the integration"""
hass.data[DOMAIN] = hacs = HacsBase()
hacs.enable_hacs()
if config is not None:
if DOMAIN not in config:
return True
if hacs.configuration.config_type == ConfigurationType.CONFIG_ENTRY:
return True
hacs.configuration.update_from_dict(
{
"config_type": ConfigurationType.YAML,
**config[DOMAIN],
"config": config[DOMAIN],
}
)
if config_entry is not None:
if config_entry.source == SOURCE_IMPORT:
hass.async_create_task(hass.config_entries.async_remove(config_entry.entry_id))
return False
hacs.configuration.update_from_dict(
{
"config_entry": config_entry,
"config_type": ConfigurationType.CONFIG_ENTRY,
**config_entry.data,
**config_entry.options,
}
)
integration = await async_get_integration(hass, DOMAIN)
hacs.set_stage(None)
hacs.log.info(STARTUP, integration.version)
clientsession = async_get_clientsession(hass)
hacs.integration = integration
hacs.version = integration.version
hacs.configuration.dev = integration.version == "0.0.0"
hacs.hass = hass
hacs.queue = QueueManager(hass=hass)
hacs.data = HacsData(hacs=hacs)
hacs.system.running = True
hacs.session = clientsession
hacs.core.lovelace_mode = LovelaceMode.YAML
try:
lovelace_info = await system_health_info(hacs.hass)
hacs.core.lovelace_mode = LovelaceMode(lovelace_info.get("mode", "yaml"))
except BaseException: # lgtm [py/catch-base-exception] pylint: disable=broad-except
# If this happens, the users YAML is not valid, we assume YAML mode
pass
hacs.log.debug("Configuration type: %s", hacs.configuration.config_type)
hacs.core.config_path = hacs.hass.config.path()
if hacs.core.ha_version is None:
hacs.core.ha_version = AwesomeVersion(HAVERSION)
## Legacy GitHub client
hacs.github = GitHub(
hacs.configuration.token,
clientsession,
headers={
"User-Agent": f"HACS/{hacs.version}",
"Accept": ACCEPT_HEADERS["preview"],
},
)
## New GitHub client
hacs.githubapi = GitHubAPI(
token=hacs.configuration.token,
session=clientsession,
**{"client_name": f"HACS/{hacs.version}"},
)
async def async_startup():
"""HACS startup tasks."""
hacs.enable_hacs()
for location in (
hass.config.path("custom_components/custom_updater.py"),
hass.config.path("custom_components/custom_updater/__init__.py"),
):
if os.path.exists(location):
hacs.log.critical(
"This cannot be used with custom_updater. "
"To use this you need to remove custom_updater form %s",
location,
)
hacs.disable_hacs(HacsDisabledReason.CONSTRAINS)
return False
if not version_left_higher_or_equal_then_right(
hacs.core.ha_version.string,
MINIMUM_HA_VERSION,
):
hacs.log.critical(
"You need HA version %s or newer to use this integration.",
MINIMUM_HA_VERSION,
)
hacs.disable_hacs(HacsDisabledReason.CONSTRAINS)
return False
if not await hacs.data.restore():
hacs.disable_hacs(HacsDisabledReason.RESTORE)
return False
can_update = await hacs.async_can_update()
hacs.log.debug("Can update %s repositories", can_update)
hacs.set_active_categories()
async_register_websocket_commands(hass)
async_register_frontend(hass, hacs)
if hacs.configuration.config_type == ConfigurationType.YAML:
hass.async_create_task(
async_load_platform(hass, Platform.SENSOR, DOMAIN, {}, hacs.configuration.config)
)
hacs.log.info("Update entities are only supported when using UI configuration")
else:
hass.config_entries.async_setup_platforms(
config_entry,
[Platform.SENSOR, Platform.UPDATE]
if hacs.configuration.experimental
else [Platform.SENSOR],
)
hacs.set_stage(HacsStage.SETUP)
if hacs.system.disabled:
return False
# Schedule startup tasks
async_at_start(hass=hass, at_start_cb=hacs.startup_tasks)
hacs.set_stage(HacsStage.WAITING)
hacs.log.info("Setup complete, waiting for Home Assistant before startup tasks starts")
return not hacs.system.disabled
async def async_try_startup(_=None):
"""Startup wrapper for yaml config."""
try:
startup_result = await async_startup()
except AIOGitHubAPIException:
startup_result = False
if not startup_result:
if (
hacs.configuration.config_type == ConfigurationType.YAML
or hacs.system.disabled_reason != HacsDisabledReason.INVALID_TOKEN
):
hacs.log.info("Could not setup HACS, trying again in 15 min")
async_call_later(hass, 900, async_try_startup)
return
hacs.enable_hacs()
await async_try_startup()
# Mischief managed!
return True
async def async_setup(hass: HomeAssistant, config: dict[str, Any]) -> bool:
"""Set up this integration using yaml."""
return await async_initialize_integration(hass=hass, config=config)
async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool:
"""Set up this integration using UI."""
config_entry.async_on_unload(config_entry.add_update_listener(async_reload_entry))
setup_result = await async_initialize_integration(hass=hass, config_entry=config_entry)
hacs: HacsBase = hass.data[DOMAIN]
return setup_result and not hacs.system.disabled
async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool:
"""Handle removal of an entry."""
hacs: HacsBase = hass.data[DOMAIN]
# Clear out pending queue
hacs.queue.clear()
for task in hacs.recuring_tasks:
# Cancel all pending tasks
task()
# Store data
await hacs.data.async_write(force=True)
try:
if hass.data.get("frontend_panels", {}).get("hacs"):
hacs.log.info("Removing sidepanel")
hass.components.frontend.async_remove_panel("hacs")
except AttributeError:
pass
platforms = ["sensor"]
if hacs.configuration.experimental:
platforms.append("update")
unload_ok = await hass.config_entries.async_unload_platforms(config_entry, platforms)
hacs.set_stage(None)
hacs.disable_hacs(HacsDisabledReason.REMOVED)
hass.data.pop(DOMAIN, None)
return unload_ok
async def async_reload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> None:
"""Reload the HACS config entry."""
await async_unload_entry(hass, config_entry)
await async_setup_entry(hass, config_entry)
@@ -0,0 +1,986 @@
"""Base HACS class."""
from __future__ import annotations
import asyncio
from dataclasses import asdict, dataclass, field
from datetime import timedelta
import gzip
import logging
import math
import os
import pathlib
import shutil
from typing import TYPE_CHECKING, Any, Awaitable, Callable
from aiogithubapi import (
AIOGitHubAPIException,
GitHub,
GitHubAPI,
GitHubAuthenticationException,
GitHubException,
GitHubNotModifiedException,
GitHubRatelimitException,
)
from aiogithubapi.objects.repository import AIOGitHubAPIRepository
from aiohttp.client import ClientSession, ClientTimeout
from awesomeversion import AwesomeVersion
from homeassistant.config_entries import ConfigEntry, ConfigEntryState
from homeassistant.const import EVENT_HOMEASSISTANT_FINAL_WRITE, Platform
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_send
from homeassistant.helpers.issue_registry import async_create_issue, IssueSeverity
from homeassistant.loader import Integration
from homeassistant.util import dt
from .const import DOMAIN, TV
from .enums import (
ConfigurationType,
HacsCategory,
HacsDisabledReason,
HacsDispatchEvent,
HacsGitHubRepo,
HacsStage,
LovelaceMode,
)
from .exceptions import (
AddonRepositoryException,
HacsException,
HacsExecutionStillInProgress,
HacsExpectedException,
HacsRepositoryArchivedException,
HacsRepositoryExistException,
HomeAssistantCoreRepositoryException,
)
from .repositories import RERPOSITORY_CLASSES
from .utils.decode import decode_content
from .utils.json import json_loads
from .utils.logger import LOGGER
from .utils.queue_manager import QueueManager
from .utils.store import async_load_from_store, async_save_to_store
if TYPE_CHECKING:
from .repositories.base import HacsRepository
from .utils.data import HacsData
from .validate.manager import ValidationManager
@dataclass
class RemovedRepository:
"""Removed repository."""
repository: str | None = None
reason: str | None = None
link: str | None = None
removal_type: str = None # archived, not_compliant, critical, dev, broken
acknowledged: bool = False
def update_data(self, data: dict):
"""Update data of the repository."""
for key in data:
if data[key] is None:
continue
if key in (
"reason",
"link",
"removal_type",
"acknowledged",
):
self.__setattr__(key, data[key])
def to_json(self):
"""Return a JSON representation of the data."""
return {
"repository": self.repository,
"reason": self.reason,
"link": self.link,
"removal_type": self.removal_type,
"acknowledged": self.acknowledged,
}
@dataclass
class HacsConfiguration:
"""HacsConfiguration class."""
appdaemon_path: str = "appdaemon/apps/"
appdaemon: bool = False
config: dict[str, Any] = field(default_factory=dict)
config_entry: ConfigEntry | None = None
config_type: ConfigurationType | None = None
country: str = "ALL"
debug: bool = False
dev: bool = False
experimental: bool = False
frontend_repo_url: str = ""
frontend_repo: str = ""
netdaemon_path: str = "netdaemon/apps/"
netdaemon: bool = False
plugin_path: str = "www/community/"
python_script_path: str = "python_scripts/"
python_script: bool = False
release_limit: int = 5
sidepanel_icon: str = "hacs:hacs"
sidepanel_title: str = "HACS"
theme_path: str = "themes/"
theme: bool = False
token: str = None
def to_json(self) -> str:
"""Return a json string."""
return asdict(self)
def update_from_dict(self, data: dict) -> None:
"""Set attributes from dicts."""
if not isinstance(data, dict):
raise HacsException("Configuration is not valid.")
for key in data:
self.__setattr__(key, data[key])
@dataclass
class HacsCore:
"""HACS Core info."""
config_path: pathlib.Path | None = None
ha_version: AwesomeVersion | None = None
lovelace_mode = LovelaceMode("yaml")
@dataclass
class HacsCommon:
"""Common for HACS."""
categories: set[str] = field(default_factory=set)
renamed_repositories: dict[str, str] = field(default_factory=dict)
archived_repositories: list[str] = field(default_factory=list)
ignored_repositories: list[str] = field(default_factory=list)
skip: list[str] = field(default_factory=list)
@dataclass
class HacsStatus:
"""HacsStatus."""
startup: bool = True
new: bool = False
@dataclass
class HacsSystem:
"""HACS System info."""
disabled_reason: HacsDisabledReason | None = None
running: bool = False
stage = HacsStage.SETUP
action: bool = False
@property
def disabled(self) -> bool:
"""Return if HACS is disabled."""
return self.disabled_reason is not None
@dataclass
class HacsRepositories:
"""HACS Repositories."""
_default_repositories: set[str] = field(default_factory=set)
_repositories: list[HacsRepository] = field(default_factory=list)
_repositories_by_full_name: dict[str, HacsRepository] = field(default_factory=dict)
_repositories_by_id: dict[str, HacsRepository] = field(default_factory=dict)
_removed_repositories: list[RemovedRepository] = field(default_factory=list)
@property
def list_all(self) -> list[HacsRepository]:
"""Return a list of repositories."""
return self._repositories
@property
def list_removed(self) -> list[RemovedRepository]:
"""Return a list of removed repositories."""
return self._removed_repositories
@property
def list_downloaded(self) -> list[HacsRepository]:
"""Return a list of downloaded repositories."""
return [repo for repo in self._repositories if repo.data.installed]
def register(self, repository: HacsRepository, default: bool = False) -> None:
"""Register a repository."""
repo_id = str(repository.data.id)
if repo_id == "0":
return
if registered_repo := self._repositories_by_id.get(repo_id):
if registered_repo.data.full_name == repository.data.full_name:
return
self.unregister(registered_repo)
registered_repo.data.full_name = repository.data.full_name
registered_repo.data.new = False
repository = registered_repo
if repository not in self._repositories:
self._repositories.append(repository)
self._repositories_by_id[repo_id] = repository
self._repositories_by_full_name[repository.data.full_name_lower] = repository
if default:
self.mark_default(repository)
def unregister(self, repository: HacsRepository) -> None:
"""Unregister a repository."""
repo_id = str(repository.data.id)
if repo_id == "0":
return
if not self.is_registered(repository_id=repo_id):
return
if self.is_default(repo_id):
self._default_repositories.remove(repo_id)
if repository in self._repositories:
self._repositories.remove(repository)
self._repositories_by_id.pop(repo_id, None)
self._repositories_by_full_name.pop(repository.data.full_name_lower, None)
def mark_default(self, repository: HacsRepository) -> None:
"""Mark a repository as default."""
repo_id = str(repository.data.id)
if repo_id == "0":
return
if not self.is_registered(repository_id=repo_id):
return
self._default_repositories.add(repo_id)
def set_repository_id(self, repository, repo_id):
"""Update a repository id."""
existing_repo_id = str(repository.data.id)
if existing_repo_id == repo_id:
return
if existing_repo_id != "0":
raise ValueError(
f"The repo id for {repository.data.full_name_lower} "
f"is already set to {existing_repo_id}"
)
repository.data.id = repo_id
self.register(repository)
def is_default(self, repository_id: str | None = None) -> bool:
"""Check if a repository is default."""
if not repository_id:
return False
return repository_id in self._default_repositories
def is_registered(
self,
repository_id: str | None = None,
repository_full_name: str | None = None,
) -> bool:
"""Check if a repository is registered."""
if repository_id is not None:
return repository_id in self._repositories_by_id
if repository_full_name is not None:
return repository_full_name in self._repositories_by_full_name
return False
def is_downloaded(
self,
repository_id: str | None = None,
repository_full_name: str | None = None,
) -> bool:
"""Check if a repository is registered."""
if repository_id is not None:
repo = self.get_by_id(repository_id)
if repository_full_name is not None:
repo = self.get_by_full_name(repository_full_name)
if repo is None:
return False
return repo.data.installed
def get_by_id(self, repository_id: str | None) -> HacsRepository | None:
"""Get repository by id."""
if not repository_id:
return None
return self._repositories_by_id.get(str(repository_id))
def get_by_full_name(self, repository_full_name: str | None) -> HacsRepository | None:
"""Get repository by full name."""
if not repository_full_name:
return None
return self._repositories_by_full_name.get(repository_full_name.lower())
def is_removed(self, repository_full_name: str) -> bool:
"""Check if a repository is removed."""
return repository_full_name in (
repository.repository for repository in self._removed_repositories
)
def removed_repository(self, repository_full_name: str) -> RemovedRepository:
"""Get repository by full name."""
if self.is_removed(repository_full_name):
if removed := [
repository
for repository in self._removed_repositories
if repository.repository == repository_full_name
]:
return removed[0]
removed = RemovedRepository(repository=repository_full_name)
self._removed_repositories.append(removed)
return removed
class HacsBase:
"""Base HACS class."""
common = HacsCommon()
configuration = HacsConfiguration()
core = HacsCore()
data: HacsData | None = None
frontend_version: str | None = None
github: GitHub | None = None
githubapi: GitHubAPI | None = None
hass: HomeAssistant | None = None
integration: Integration | None = None
log: logging.Logger = LOGGER
queue: QueueManager | None = None
recuring_tasks = []
repositories: HacsRepositories = HacsRepositories()
repository: AIOGitHubAPIRepository | None = None
session: ClientSession | None = None
stage: HacsStage | None = None
status = HacsStatus()
system = HacsSystem()
validation: ValidationManager | None = None
version: str | None = None
@property
def integration_dir(self) -> pathlib.Path:
"""Return the HACS integration dir."""
return self.integration.file_path
def set_stage(self, stage: HacsStage | None) -> None:
"""Set HACS stage."""
if stage and self.stage == stage:
return
self.stage = stage
if stage is not None:
self.log.info("Stage changed: %s", self.stage)
self.async_dispatch(HacsDispatchEvent.STAGE, {"stage": self.stage})
def disable_hacs(self, reason: HacsDisabledReason) -> None:
"""Disable HACS."""
if self.system.disabled_reason == reason:
return
self.system.disabled_reason = reason
if reason != HacsDisabledReason.REMOVED:
self.log.error("HACS is disabled - %s", reason)
if (
reason == HacsDisabledReason.INVALID_TOKEN
and self.configuration.config_type == ConfigurationType.CONFIG_ENTRY
):
self.configuration.config_entry.state = ConfigEntryState.SETUP_ERROR
self.configuration.config_entry.reason = "Authentication failed"
self.hass.add_job(self.configuration.config_entry.async_start_reauth, self.hass)
def enable_hacs(self) -> None:
"""Enable HACS."""
if self.system.disabled_reason is not None:
self.system.disabled_reason = None
self.log.info("HACS is enabled")
def enable_hacs_category(self, category: HacsCategory) -> None:
"""Enable HACS category."""
if category not in self.common.categories:
self.log.info("Enable category: %s", category)
self.common.categories.add(category)
def disable_hacs_category(self, category: HacsCategory) -> None:
"""Disable HACS category."""
if category in self.common.categories:
self.log.info("Disabling category: %s", category)
self.common.categories.pop(category)
async def async_save_file(self, file_path: str, content: Any) -> bool:
"""Save a file."""
def _write_file():
with open(
file_path,
mode="w" if isinstance(content, str) else "wb",
encoding="utf-8" if isinstance(content, str) else None,
errors="ignore" if isinstance(content, str) else None,
) as file_handler:
file_handler.write(content)
# Create gz for .js files
if os.path.isfile(file_path):
if file_path.endswith(".js"):
with open(file_path, "rb") as f_in:
with gzip.open(file_path + ".gz", "wb") as f_out:
shutil.copyfileobj(f_in, f_out)
# LEGACY! Remove with 2.0
if "themes" in file_path and file_path.endswith(".yaml"):
filename = file_path.split("/")[-1]
base = file_path.split("/themes/")[0]
combined = f"{base}/themes/{filename}"
if os.path.exists(combined):
self.log.info("Removing old theme file %s", combined)
os.remove(combined)
try:
await self.hass.async_add_executor_job(_write_file)
except BaseException as error: # lgtm [py/catch-base-exception] pylint: disable=broad-except
self.log.error("Could not write data to %s - %s", file_path, error)
return False
return os.path.exists(file_path)
async def async_can_update(self) -> int:
"""Helper to calculate the number of repositories we can fetch data for."""
try:
response = await self.async_github_api_method(self.githubapi.rate_limit)
if ((limit := response.data.resources.core.remaining or 0) - 1000) >= 10:
return math.floor((limit - 1000) / 10)
reset = dt.as_local(dt.utc_from_timestamp(response.data.resources.core.reset))
self.log.info(
"GitHub API ratelimited - %s remaining (%s)",
response.data.resources.core.remaining,
f"{reset.hour}:{reset.minute}:{reset.second}",
)
self.disable_hacs(HacsDisabledReason.RATE_LIMIT)
except BaseException as exception: # lgtm [py/catch-base-exception] pylint: disable=broad-except
self.log.exception(exception)
return 0
async def async_github_get_hacs_default_file(self, filename: str) -> list:
"""Get the content of a default file."""
response = await self.async_github_api_method(
method=self.githubapi.repos.contents.get,
repository=HacsGitHubRepo.DEFAULT,
path=filename,
)
if response is None:
return []
return json_loads(decode_content(response.data.content))
async def async_github_api_method(
self,
method: Callable[[], Awaitable[TV]],
*args,
raise_exception: bool = True,
**kwargs,
) -> TV | None:
"""Call a GitHub API method"""
_exception = None
try:
return await method(*args, **kwargs)
except GitHubAuthenticationException as exception:
self.disable_hacs(HacsDisabledReason.INVALID_TOKEN)
_exception = exception
except GitHubRatelimitException as exception:
self.disable_hacs(HacsDisabledReason.RATE_LIMIT)
_exception = exception
except GitHubNotModifiedException as exception:
raise exception
except GitHubException as exception:
_exception = exception
except BaseException as exception: # lgtm [py/catch-base-exception] pylint: disable=broad-except
self.log.exception(exception)
_exception = exception
if raise_exception and _exception is not None:
raise HacsException(_exception)
return None
async def async_register_repository(
self,
repository_full_name: str,
category: HacsCategory,
*,
check: bool = True,
ref: str | None = None,
repository_id: str | None = None,
default: bool = False,
) -> None:
"""Register a repository."""
if repository_full_name in self.common.skip:
if repository_full_name != HacsGitHubRepo.INTEGRATION:
raise HacsExpectedException(f"Skipping {repository_full_name}")
if repository_full_name == "home-assistant/core":
raise HomeAssistantCoreRepositoryException()
if repository_full_name == "home-assistant/addons" or repository_full_name.startswith(
"hassio-addons/"
):
raise AddonRepositoryException()
if category not in RERPOSITORY_CLASSES:
raise HacsException(f"{category} is not a valid repository category.")
if (renamed := self.common.renamed_repositories.get(repository_full_name)) is not None:
repository_full_name = renamed
repository: HacsRepository = RERPOSITORY_CLASSES[category](self, repository_full_name)
if check:
try:
await repository.async_registration(ref)
if self.status.new:
repository.data.new = False
if repository.validate.errors:
self.common.skip.append(repository.data.full_name)
if not self.status.startup:
self.log.error("Validation for %s failed.", repository_full_name)
if self.system.action:
raise HacsException(
f"::error:: Validation for {repository_full_name} failed."
)
return repository.validate.errors
if self.system.action:
repository.logger.info("%s Validation completed", repository.string)
else:
repository.logger.info("%s Registration completed", repository.string)
except (HacsRepositoryExistException, HacsRepositoryArchivedException):
return
except AIOGitHubAPIException as exception:
self.common.skip.append(repository.data.full_name)
raise HacsException(
f"Validation for {repository_full_name} failed with {exception}."
) from exception
if repository_id is not None:
repository.data.id = repository_id
else:
if self.hass is not None and ((check and repository.data.new) or self.status.new):
self.async_dispatch(
HacsDispatchEvent.REPOSITORY,
{
"action": "registration",
"repository": repository.data.full_name,
"repository_id": repository.data.id,
},
)
self.repositories.register(repository, default)
async def startup_tasks(self, _=None) -> None:
"""Tasks that are started after setup."""
self.set_stage(HacsStage.STARTUP)
try:
repository = self.repositories.get_by_full_name(HacsGitHubRepo.INTEGRATION)
if repository is None:
await self.async_register_repository(
repository_full_name=HacsGitHubRepo.INTEGRATION,
category=HacsCategory.INTEGRATION,
default=True,
)
repository = self.repositories.get_by_full_name(HacsGitHubRepo.INTEGRATION)
if repository is None:
raise HacsException("Unknown error")
repository.data.installed = True
repository.data.installed_version = self.integration.version.string
repository.data.new = False
repository.data.releases = True
self.repository = repository.repository_object
self.repositories.mark_default(repository)
except HacsException as exception:
if "403" in str(exception):
self.log.critical(
"GitHub API is ratelimited, or the token is wrong.",
)
else:
self.log.critical("Could not load HACS! - %s", exception)
self.disable_hacs(HacsDisabledReason.LOAD_HACS)
if critical := await async_load_from_store(self.hass, "critical"):
for repo in critical:
if not repo["acknowledged"]:
self.log.critical("URGENT!: Check the HACS panel!")
self.hass.components.persistent_notification.create(
title="URGENT!", message="**Check the HACS panel!**"
)
break
self.recuring_tasks.append(
self.hass.helpers.event.async_track_time_interval(
self.async_get_all_category_repositories, timedelta(hours=3)
)
)
self.recuring_tasks.append(
self.hass.helpers.event.async_track_time_interval(
self.async_update_all_repositories, timedelta(hours=25)
)
)
self.recuring_tasks.append(
self.hass.helpers.event.async_track_time_interval(
self.async_check_rate_limit, timedelta(minutes=5)
)
)
self.recuring_tasks.append(
self.hass.helpers.event.async_track_time_interval(
self.async_prosess_queue, timedelta(minutes=10)
)
)
self.recuring_tasks.append(
self.hass.helpers.event.async_track_time_interval(
self.async_update_downloaded_repositories, timedelta(hours=2)
)
)
self.recuring_tasks.append(
self.hass.helpers.event.async_track_time_interval(
self.async_handle_critical_repositories, timedelta(hours=2)
)
)
self.hass.bus.async_listen_once(
EVENT_HOMEASSISTANT_FINAL_WRITE, self.data.async_force_write
)
self.status.startup = False
self.async_dispatch(HacsDispatchEvent.STATUS, {})
await self.async_handle_removed_repositories()
await self.async_get_all_category_repositories()
await self.async_update_downloaded_repositories()
self.set_stage(HacsStage.RUNNING)
self.async_dispatch(HacsDispatchEvent.RELOAD, {"force": True})
await self.async_handle_critical_repositories()
await self.async_prosess_queue()
self.async_dispatch(HacsDispatchEvent.STATUS, {})
async def async_download_file(self, url: str, *, headers: dict | None = None) -> bytes | None:
"""Download files, and return the content."""
if url is None:
return None
if "tags/" in url:
url = url.replace("tags/", "")
self.log.debug("Downloading %s", url)
timeouts = 0
while timeouts < 5:
try:
request = await self.session.get(
url=url,
timeout=ClientTimeout(total=60),
headers=headers,
)
# Make sure that we got a valid result
if request.status == 200:
return await request.read()
raise HacsException(
f"Got status code {request.status} when trying to download {url}"
)
except asyncio.TimeoutError:
self.log.warning(
"A timeout of 60! seconds was encountered while downloading %s, "
"using over 60 seconds to download a single file is not normal. "
"This is not a problem with HACS but how your host communicates with GitHub. "
"Retrying up to 5 times to mask/hide your host/network problems to "
"stop the flow of issues opened about it. "
"Tries left %s",
url,
(4 - timeouts),
)
timeouts += 1
await asyncio.sleep(1)
continue
except BaseException as exception: # lgtm [py/catch-base-exception] pylint: disable=broad-except
self.log.exception("Download failed - %s", exception)
return None
async def async_recreate_entities(self) -> None:
"""Recreate entities."""
if self.configuration == ConfigurationType.YAML or not self.configuration.experimental:
return
platforms = [Platform.SENSOR, Platform.UPDATE]
await self.hass.config_entries.async_unload_platforms(
entry=self.configuration.config_entry,
platforms=platforms,
)
self.hass.config_entries.async_setup_platforms(self.configuration.config_entry, platforms)
@callback
def async_dispatch(self, signal: HacsDispatchEvent, data: dict | None = None) -> None:
"""Dispatch a signal with data."""
async_dispatcher_send(self.hass, signal, data)
def set_active_categories(self) -> None:
"""Set the active categories."""
self.common.categories = set()
for category in (HacsCategory.INTEGRATION, HacsCategory.PLUGIN):
self.enable_hacs_category(HacsCategory(category))
if HacsCategory.PYTHON_SCRIPT in self.hass.config.components:
self.enable_hacs_category(HacsCategory.PYTHON_SCRIPT)
if self.hass.services.has_service("frontend", "reload_themes"):
self.enable_hacs_category(HacsCategory.THEME)
if self.configuration.appdaemon:
self.enable_hacs_category(HacsCategory.APPDAEMON)
if self.configuration.netdaemon:
self.enable_hacs_category(HacsCategory.NETDAEMON)
async def async_get_all_category_repositories(self, _=None) -> None:
"""Get all category repositories."""
if self.system.disabled:
return
self.log.info("Loading known repositories")
await asyncio.gather(
*[
self.async_get_category_repositories(HacsCategory(category))
for category in self.common.categories or []
]
)
async def async_get_category_repositories(self, category: HacsCategory) -> None:
"""Get repositories from category."""
if self.system.disabled:
return
try:
repositories = await self.async_github_get_hacs_default_file(category)
except HacsException:
return
for repo in repositories:
if self.common.renamed_repositories.get(repo):
repo = self.common.renamed_repositories[repo]
if self.repositories.is_removed(repo):
continue
if repo in self.common.archived_repositories:
continue
repository = self.repositories.get_by_full_name(repo)
if repository is not None:
self.repositories.mark_default(repository)
if self.status.new and self.configuration.dev:
# Force update for new installations
self.queue.add(repository.common_update())
continue
self.queue.add(
self.async_register_repository(
repository_full_name=repo,
category=category,
default=True,
)
)
async def async_update_all_repositories(self, _=None) -> None:
"""Update all repositories."""
if self.system.disabled:
return
self.log.debug("Starting recurring background task for all repositories")
for repository in self.repositories.list_all:
if repository.data.category in self.common.categories:
self.queue.add(repository.common_update())
self.async_dispatch(HacsDispatchEvent.REPOSITORY, {"action": "reload"})
self.log.debug("Recurring background task for all repositories done")
async def async_check_rate_limit(self, _=None) -> None:
"""Check rate limit."""
if not self.system.disabled or self.system.disabled_reason != HacsDisabledReason.RATE_LIMIT:
return
self.log.debug("Checking if ratelimit has lifted")
can_update = await self.async_can_update()
self.log.debug("Ratelimit indicate we can update %s", can_update)
if can_update > 0:
self.enable_hacs()
await self.async_prosess_queue()
async def async_prosess_queue(self, _=None) -> None:
"""Process the queue."""
if self.system.disabled:
self.log.debug("HACS is disabled")
return
if not self.queue.has_pending_tasks:
self.log.debug("Nothing in the queue")
return
if self.queue.running:
self.log.debug("Queue is already running")
return
async def _handle_queue():
if not self.queue.has_pending_tasks:
await self.data.async_write()
return
can_update = await self.async_can_update()
self.log.debug(
"Can update %s repositories, " "items in queue %s",
can_update,
self.queue.pending_tasks,
)
if can_update != 0:
try:
await self.queue.execute(can_update)
except HacsExecutionStillInProgress:
return
await _handle_queue()
await _handle_queue()
async def async_handle_removed_repositories(self, _=None) -> None:
"""Handle removed repositories."""
if self.system.disabled:
return
need_to_save = False
self.log.info("Loading removed repositories")
try:
removed_repositories = await self.async_github_get_hacs_default_file(
HacsCategory.REMOVED
)
except HacsException:
return
for item in removed_repositories:
removed = self.repositories.removed_repository(item["repository"])
removed.update_data(item)
for removed in self.repositories.list_removed:
if (repository := self.repositories.get_by_full_name(removed.repository)) is None:
continue
if repository.data.full_name in self.common.ignored_repositories:
continue
if repository.data.installed:
if removed.removal_type != "critical":
if self.configuration.experimental:
async_create_issue(
hass=self.hass,
domain=DOMAIN,
issue_id=f"removed_{self.data.id}",
is_fixable=False,
issue_domain=DOMAIN,
severity=IssueSeverity.WARNING,
translation_key="removed",
translation_placeholders={
"name": repository.data.full_name,
"reason": removed.reason,
"repositry_id": repository.data.id,
},
)
self.log.warning(
"You have '%s' installed with HACS "
"this repository has been removed from HACS, please consider removing it. "
"Removal reason (%s)",
repository.data.full_name,
removed.reason,
)
else:
need_to_save = True
repository.remove()
if need_to_save:
await self.data.async_write()
async def async_update_downloaded_repositories(self, _=None) -> None:
"""Execute the task."""
if self.system.disabled:
return
self.log.info("Starting recurring background task for downloaded repositories")
for repository in self.repositories.list_downloaded:
if repository.data.category in self.common.categories:
self.queue.add(repository.update_repository(ignore_issues=True))
self.log.debug("Recurring background task for downloaded repositories done")
async def async_handle_critical_repositories(self, _=None) -> None:
"""Handle critical repositories."""
critical_queue = QueueManager(hass=self.hass)
instored = []
critical = []
was_installed = False
try:
critical = await self.async_github_get_hacs_default_file("critical")
except GitHubNotModifiedException:
return
except HacsException:
pass
if not critical:
self.log.debug("No critical repositories")
return
stored_critical = await async_load_from_store(self.hass, "critical")
for stored in stored_critical or []:
instored.append(stored["repository"])
stored_critical = []
for repository in critical:
removed_repo = self.repositories.removed_repository(repository["repository"])
removed_repo.removal_type = "critical"
repo = self.repositories.get_by_full_name(repository["repository"])
stored = {
"repository": repository["repository"],
"reason": repository["reason"],
"link": repository["link"],
"acknowledged": True,
}
if repository["repository"] not in instored:
if repo is not None and repo.data.installed:
self.log.critical(
"Removing repository %s, it is marked as critical",
repository["repository"],
)
was_installed = True
stored["acknowledged"] = False
# Remove from HACS
critical_queue.add(repo.uninstall())
repo.remove()
stored_critical.append(stored)
removed_repo.update_data(stored)
# Uninstall
await critical_queue.execute()
# Save to FS
await async_save_to_store(self.hass, "critical", stored_critical)
# Restart HASS
if was_installed:
self.log.critical("Restarting Home Assistant")
self.hass.async_create_task(self.hass.async_stop(100))
@@ -0,0 +1,182 @@
"""Adds config flow for HACS."""
from aiogithubapi import GitHubDeviceAPI, GitHubException
from aiogithubapi.common.const import OAUTH_USER_LOGIN
from awesomeversion import AwesomeVersion
from homeassistant import config_entries
from homeassistant.const import __version__ as HAVERSION
from homeassistant.core import callback
from homeassistant.helpers import aiohttp_client
from homeassistant.helpers.event import async_call_later
from homeassistant.loader import async_get_integration
import voluptuous as vol
from .base import HacsBase
from .const import CLIENT_ID, DOMAIN, MINIMUM_HA_VERSION
from .enums import ConfigurationType
from .utils.configuration_schema import RELEASE_LIMIT, hacs_config_option_schema
from .utils.logger import LOGGER
class HacsFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
"""Config flow for HACS."""
VERSION = 1
CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL
def __init__(self):
"""Initialize."""
self._errors = {}
self.device = None
self.activation = None
self.log = LOGGER
self._progress_task = None
self._login_device = None
self._reauth = False
async def async_step_user(self, user_input):
"""Handle a flow initialized by the user."""
self._errors = {}
if self._async_current_entries():
return self.async_abort(reason="single_instance_allowed")
if self.hass.data.get(DOMAIN):
return self.async_abort(reason="single_instance_allowed")
if user_input:
if [x for x in user_input if not user_input[x]]:
self._errors["base"] = "acc"
return await self._show_config_form(user_input)
return await self.async_step_device(user_input)
## Initial form
return await self._show_config_form(user_input)
async def async_step_device(self, _user_input):
"""Handle device steps"""
async def _wait_for_activation(_=None):
if self._login_device is None or self._login_device.expires_in is None:
async_call_later(self.hass, 1, _wait_for_activation)
return
response = await self.device.activation(device_code=self._login_device.device_code)
self.activation = response.data
self.hass.async_create_task(
self.hass.config_entries.flow.async_configure(flow_id=self.flow_id)
)
if not self.activation:
integration = await async_get_integration(self.hass, DOMAIN)
if not self.device:
self.device = GitHubDeviceAPI(
client_id=CLIENT_ID,
session=aiohttp_client.async_get_clientsession(self.hass),
**{"client_name": f"HACS/{integration.version}"},
)
async_call_later(self.hass, 1, _wait_for_activation)
try:
response = await self.device.register()
self._login_device = response.data
return self.async_show_progress(
step_id="device",
progress_action="wait_for_device",
description_placeholders={
"url": OAUTH_USER_LOGIN,
"code": self._login_device.user_code,
},
)
except GitHubException as exception:
self.log.error(exception)
return self.async_abort(reason="github")
return self.async_show_progress_done(next_step_id="device_done")
async def _show_config_form(self, user_input):
"""Show the configuration form to edit location data."""
if not user_input:
user_input = {}
if AwesomeVersion(HAVERSION) < MINIMUM_HA_VERSION:
return self.async_abort(
reason="min_ha_version",
description_placeholders={"version": MINIMUM_HA_VERSION},
)
return self.async_show_form(
step_id="user",
data_schema=vol.Schema(
{
vol.Required("acc_logs", default=user_input.get("acc_logs", False)): bool,
vol.Required("acc_addons", default=user_input.get("acc_addons", False)): bool,
vol.Required(
"acc_untested", default=user_input.get("acc_untested", False)
): bool,
vol.Required("acc_disable", default=user_input.get("acc_disable", False)): bool,
}
),
errors=self._errors,
)
async def async_step_device_done(self, _user_input):
"""Handle device steps"""
if self._reauth:
existing_entry = self.hass.config_entries.async_get_entry(self.context["entry_id"])
self.hass.config_entries.async_update_entry(
existing_entry, data={"token": self.activation.access_token}
)
await self.hass.config_entries.async_reload(existing_entry.entry_id)
return self.async_abort(reason="reauth_successful")
return self.async_create_entry(title="", data={"token": self.activation.access_token})
async def async_step_reauth(self, user_input=None):
"""Perform reauth upon an API authentication error."""
return await self.async_step_reauth_confirm()
async def async_step_reauth_confirm(self, user_input=None):
"""Dialog that informs the user that reauth is required."""
if user_input is None:
return self.async_show_form(
step_id="reauth_confirm",
data_schema=vol.Schema({}),
)
self._reauth = True
return await self.async_step_device(None)
@staticmethod
@callback
def async_get_options_flow(config_entry):
return HacsOptionsFlowHandler(config_entry)
class HacsOptionsFlowHandler(config_entries.OptionsFlow):
"""HACS config flow options handler."""
def __init__(self, config_entry):
"""Initialize HACS options flow."""
self.config_entry = config_entry
async def async_step_init(self, _user_input=None):
"""Manage the options."""
return await self.async_step_user()
async def async_step_user(self, user_input=None):
"""Handle a flow initialized by the user."""
hacs: HacsBase = self.hass.data.get(DOMAIN)
if user_input is not None:
limit = int(user_input.get(RELEASE_LIMIT, 5))
if limit <= 0 or limit > 100:
return self.async_abort(reason="release_limit_value")
return self.async_create_entry(title="", data=user_input)
if hacs is None or hacs.configuration is None:
return self.async_abort(reason="not_setup")
if hacs.configuration.config_type == ConfigurationType.YAML:
schema = {vol.Optional("not_in_use", default=""): str}
else:
schema = hacs_config_option_schema(self.config_entry.options)
del schema["frontend_repo"]
del schema["frontend_repo_url"]
return self.async_show_form(step_id="user", data_schema=vol.Schema(schema))
@@ -0,0 +1,289 @@
"""Constants for HACS"""
from typing import TypeVar
from aiogithubapi.common.const import ACCEPT_HEADERS
NAME_SHORT = "HACS"
DOMAIN = "hacs"
CLIENT_ID = "395a8e669c5de9f7c6e8"
MINIMUM_HA_VERSION = "2022.10.0"
TV = TypeVar("TV")
PACKAGE_NAME = "custom_components.hacs"
DEFAULT_CONCURRENT_TASKS = 15
DEFAULT_CONCURRENT_BACKOFF_TIME = 1
HACS_ACTION_GITHUB_API_HEADERS = {
"User-Agent": "HACS/action",
"Accept": ACCEPT_HEADERS["preview"],
}
VERSION_STORAGE = "6"
STORENAME = "hacs"
HACS_SYSTEM_ID = "0717a0cd-745c-48fd-9b16-c8534c9704f9-bc944b0f-fd42-4a58-a072-ade38d1444cd"
STARTUP = """
-------------------------------------------------------------------
HACS (Home Assistant Community Store)
Version: %s
This is a custom integration
If you have any issues with this you need to open an issue here:
https://github.com/hacs/integration/issues
-------------------------------------------------------------------
"""
LOCALE = [
"ALL",
"AF",
"AL",
"DZ",
"AS",
"AD",
"AO",
"AI",
"AQ",
"AG",
"AR",
"AM",
"AW",
"AU",
"AT",
"AZ",
"BS",
"BH",
"BD",
"BB",
"BY",
"BE",
"BZ",
"BJ",
"BM",
"BT",
"BO",
"BQ",
"BA",
"BW",
"BV",
"BR",
"IO",
"BN",
"BG",
"BF",
"BI",
"KH",
"CM",
"CA",
"CV",
"KY",
"CF",
"TD",
"CL",
"CN",
"CX",
"CC",
"CO",
"KM",
"CG",
"CD",
"CK",
"CR",
"HR",
"CU",
"CW",
"CY",
"CZ",
"CI",
"DK",
"DJ",
"DM",
"DO",
"EC",
"EG",
"SV",
"GQ",
"ER",
"EE",
"ET",
"FK",
"FO",
"FJ",
"FI",
"FR",
"GF",
"PF",
"TF",
"GA",
"GM",
"GE",
"DE",
"GH",
"GI",
"GR",
"GL",
"GD",
"GP",
"GU",
"GT",
"GG",
"GN",
"GW",
"GY",
"HT",
"HM",
"VA",
"HN",
"HK",
"HU",
"IS",
"IN",
"ID",
"IR",
"IQ",
"IE",
"IM",
"IL",
"IT",
"JM",
"JP",
"JE",
"JO",
"KZ",
"KE",
"KI",
"KP",
"KR",
"KW",
"KG",
"LA",
"LV",
"LB",
"LS",
"LR",
"LY",
"LI",
"LT",
"LU",
"MO",
"MK",
"MG",
"MW",
"MY",
"MV",
"ML",
"MT",
"MH",
"MQ",
"MR",
"MU",
"YT",
"MX",
"FM",
"MD",
"MC",
"MN",
"ME",
"MS",
"MA",
"MZ",
"MM",
"NA",
"NR",
"NP",
"NL",
"NC",
"NZ",
"NI",
"NE",
"NG",
"NU",
"NF",
"MP",
"NO",
"OM",
"PK",
"PW",
"PS",
"PA",
"PG",
"PY",
"PE",
"PH",
"PN",
"PL",
"PT",
"PR",
"QA",
"RO",
"RU",
"RW",
"RE",
"BL",
"SH",
"KN",
"LC",
"MF",
"PM",
"VC",
"WS",
"SM",
"ST",
"SA",
"SN",
"RS",
"SC",
"SL",
"SG",
"SX",
"SK",
"SI",
"SB",
"SO",
"ZA",
"GS",
"SS",
"ES",
"LK",
"SD",
"SR",
"SJ",
"SZ",
"SE",
"CH",
"SY",
"TW",
"TJ",
"TZ",
"TH",
"TL",
"TG",
"TK",
"TO",
"TT",
"TN",
"TR",
"TM",
"TC",
"TV",
"UG",
"UA",
"AE",
"GB",
"US",
"UM",
"UY",
"UZ",
"VU",
"VE",
"VN",
"VG",
"VI",
"WF",
"EH",
"YE",
"ZM",
"ZW",
]
@@ -0,0 +1,82 @@
"""Diagnostics support for HACS."""
from __future__ import annotations
from typing import Any
from aiogithubapi import GitHubException
from homeassistant.components.diagnostics import async_redact_data
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from .base import HacsBase
from .const import DOMAIN
from .utils.configuration_schema import TOKEN
async def async_get_config_entry_diagnostics(
hass: HomeAssistant,
entry: ConfigEntry,
) -> dict[str, Any]:
"""Return diagnostics for a config entry."""
hacs: HacsBase = hass.data[DOMAIN]
data = {
"entry": entry.as_dict(),
"hacs": {
"stage": hacs.stage,
"version": hacs.version,
"disabled_reason": hacs.system.disabled_reason,
"new": hacs.status.new,
"startup": hacs.status.startup,
"categories": hacs.common.categories,
"renamed_repositories": hacs.common.renamed_repositories,
"archived_repositories": hacs.common.archived_repositories,
"ignored_repositories": hacs.common.ignored_repositories,
"lovelace_mode": hacs.core.lovelace_mode,
"configuration": {},
},
"custom_repositories": [
repo.data.full_name
for repo in hacs.repositories.list_all
if not hacs.repositories.is_default(str(repo.data.id))
],
"repositories": [],
}
for key in (
"appdaemon",
"country",
"debug",
"dev",
"experimental",
"netdaemon",
"python_script",
"release_limit",
"theme",
):
data["hacs"]["configuration"][key] = getattr(hacs.configuration, key, None)
for repository in hacs.repositories.list_downloaded:
data["repositories"].append(
{
"data": repository.data.to_json(),
"integration_manifest": repository.integration_manifest,
"repository_manifest": repository.repository_manifest.to_dict(),
"ref": repository.ref,
"paths": {
"localpath": repository.localpath.replace(hacs.core.config_path, "/config"),
"local": repository.content.path.local.replace(
hacs.core.config_path, "/config"
),
"remote": repository.content.path.remote,
},
}
)
try:
rate_limit_response = await hacs.githubapi.rate_limit()
data["rate_limit"] = rate_limit_response.data.as_dict
except GitHubException as exception:
data["rate_limit"] = str(exception)
return async_redact_data(data, (TOKEN,))
@@ -0,0 +1,119 @@
"""HACS Base entities."""
from __future__ import annotations
from typing import TYPE_CHECKING, Any
from homeassistant.core import callback
from homeassistant.helpers.device_registry import DeviceEntryType
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity import Entity
from .const import DOMAIN, HACS_SYSTEM_ID, NAME_SHORT
from .enums import HacsDispatchEvent, HacsGitHubRepo
if TYPE_CHECKING:
from .base import HacsBase
from .repositories.base import HacsRepository
def system_info(hacs: HacsBase) -> dict:
"""Return system info."""
return {
"identifiers": {(DOMAIN, HACS_SYSTEM_ID)},
"name": NAME_SHORT,
"manufacturer": "hacs.xyz",
"model": "",
"sw_version": str(hacs.version),
"configuration_url": "homeassistant://hacs",
"entry_type": DeviceEntryType.SERVICE,
}
class HacsBaseEntity(Entity):
"""Base HACS entity."""
repository: HacsRepository | None = None
_attr_should_poll = False
def __init__(self, hacs: HacsBase) -> None:
"""Initialize."""
self.hacs = hacs
async def async_added_to_hass(self) -> None:
"""Register for status events."""
self.async_on_remove(
async_dispatcher_connect(
self.hass,
HacsDispatchEvent.REPOSITORY,
self._update_and_write_state,
)
)
@callback
def _update(self) -> None:
"""Update the sensor."""
async def async_update(self) -> None:
"""Manual updates of the sensor."""
self._update()
@callback
def _update_and_write_state(self, _: Any) -> None:
"""Update the entity and write state."""
self._update()
self.async_write_ha_state()
class HacsSystemEntity(HacsBaseEntity):
"""Base system entity."""
_attr_icon = "hacs:hacs"
_attr_unique_id = HACS_SYSTEM_ID
@property
def device_info(self) -> dict[str, any]:
"""Return device information about HACS."""
return system_info(self.hacs)
class HacsRepositoryEntity(HacsBaseEntity):
"""Base repository entity."""
def __init__(
self,
hacs: HacsBase,
repository: HacsRepository,
) -> None:
"""Initialize."""
super().__init__(hacs=hacs)
self.repository = repository
self._attr_unique_id = str(repository.data.id)
@property
def available(self) -> bool:
"""Return True if entity is available."""
return self.hacs.repositories.is_downloaded(repository_id=str(self.repository.data.id))
@property
def device_info(self) -> dict[str, any]:
"""Return device information about HACS."""
if self.repository.data.full_name == HacsGitHubRepo.INTEGRATION:
return system_info(self.hacs)
return {
"identifiers": {(DOMAIN, str(self.repository.data.id))},
"name": self.repository.display_name,
"model": self.repository.data.category,
"manufacturer": ", ".join(
author.replace("@", "") for author in self.repository.data.authors
),
"configuration_url": "homeassistant://hacs",
"entry_type": DeviceEntryType.SERVICE,
}
@callback
def _update_and_write_state(self, data: dict) -> None:
"""Update the entity and write state."""
if data.get("repository_id") == self.repository.data.id:
self._update()
self.async_write_ha_state()
@@ -0,0 +1,75 @@
"""Helper constants."""
# pylint: disable=missing-class-docstring
from enum import Enum
class HacsGitHubRepo(str, Enum):
"""HacsGitHubRepo."""
DEFAULT = "hacs/default"
INTEGRATION = "hacs/integration"
class HacsCategory(str, Enum):
APPDAEMON = "appdaemon"
INTEGRATION = "integration"
LOVELACE = "lovelace"
PLUGIN = "plugin" # Kept for legacy purposes
NETDAEMON = "netdaemon"
PYTHON_SCRIPT = "python_script"
THEME = "theme"
REMOVED = "removed"
def __str__(self):
return str(self.value)
class HacsDispatchEvent(str, Enum):
"""HacsDispatchEvent."""
CONFIG = "hacs_dispatch_config"
ERROR = "hacs_dispatch_error"
RELOAD = "hacs_dispatch_reload"
REPOSITORY = "hacs_dispatch_repository"
REPOSITORY_DOWNLOAD_PROGRESS = "hacs_dispatch_repository_download_progress"
STAGE = "hacs_dispatch_stage"
STARTUP = "hacs_dispatch_startup"
STATUS = "hacs_dispatch_status"
class RepositoryFile(str, Enum):
"""Repository file names."""
HACS_JSON = "hacs.json"
MAINIFEST_JSON = "manifest.json"
class ConfigurationType(str, Enum):
YAML = "yaml"
CONFIG_ENTRY = "config_entry"
class LovelaceMode(str, Enum):
"""Lovelace Modes."""
STORAGE = "storage"
AUTO = "auto"
AUTO_GEN = "auto-gen"
YAML = "yaml"
class HacsStage(str, Enum):
SETUP = "setup"
STARTUP = "startup"
WAITING = "waiting"
RUNNING = "running"
BACKGROUND = "background"
class HacsDisabledReason(str, Enum):
RATE_LIMIT = "rate_limit"
REMOVED = "removed"
INVALID_TOKEN = "invalid_token"
CONSTRAINS = "constrains"
LOAD_HACS = "load_hacs"
RESTORE = "restore"
@@ -0,0 +1,49 @@
"""Custom Exceptions for HACS."""
class HacsException(Exception):
"""Super basic."""
class HacsRepositoryArchivedException(HacsException):
"""For repositories that are archived."""
class HacsNotModifiedException(HacsException):
"""For responses that are not modified."""
class HacsExpectedException(HacsException):
"""For stuff that are expected."""
class HacsRepositoryExistException(HacsException):
"""For repositories that are already exist."""
class HacsExecutionStillInProgress(HacsException):
"""Exception to raise if execution is still in progress."""
class AddonRepositoryException(HacsException):
"""Exception to raise when user tries to add add-on repository."""
exception_message = (
"The repository does not seem to be a integration, "
"but an add-on repository. HACS does not manage add-ons."
)
def __init__(self) -> None:
super().__init__(self.exception_message)
class HomeAssistantCoreRepositoryException(HacsException):
"""Exception to raise when user tries to add the home-assistant/core repository."""
exception_message = (
"You can not add homeassistant/core, to use core integrations "
"check the Home Assistant documentation for how to add them."
)
def __init__(self) -> None:
super().__init__(self.exception_message)
@@ -0,0 +1,107 @@
""""Starting setup task: Frontend"."""
from __future__ import annotations
from typing import TYPE_CHECKING
from aiohttp import web
from homeassistant.components.http import HomeAssistantView
from homeassistant.core import HomeAssistant, callback
from .const import DOMAIN
from .hacs_frontend import locate_dir, VERSION as FE_VERSION
from .hacs_frontend_experimental import (
locate_dir as experimental_locate_dir,
VERSION as EXPERIMENTAL_FE_VERSION,
)
URL_BASE = "/hacsfiles"
if TYPE_CHECKING:
from .base import HacsBase
@callback
def async_register_frontend(hass: HomeAssistant, hacs: HacsBase) -> None:
"""Register the frontend."""
# Register themes
hass.http.register_static_path(f"{URL_BASE}/themes", hass.config.path("themes"))
# Register frontend
if hacs.configuration.frontend_repo_url:
hacs.log.warning(
"<HacsFrontend> Frontend development mode enabled. Do not run in production!"
)
hass.http.register_view(HacsFrontendDev())
elif hacs.configuration.experimental:
hacs.log.info("<HacsFrontend> Using experimental frontend")
hass.http.register_static_path(
f"{URL_BASE}/frontend", experimental_locate_dir(), cache_headers=False
)
else:
#
hass.http.register_static_path(f"{URL_BASE}/frontend", locate_dir(), cache_headers=False)
# Custom iconset
hass.http.register_static_path(
f"{URL_BASE}/iconset.js", str(hacs.integration_dir / "iconset.js")
)
if "frontend_extra_module_url" not in hass.data:
hass.data["frontend_extra_module_url"] = set()
hass.data["frontend_extra_module_url"].add(f"{URL_BASE}/iconset.js")
# Register www/community for all other files
use_cache = hacs.core.lovelace_mode == "storage"
hacs.log.info(
"<HacsFrontend> %s mode, cache for /hacsfiles/: %s",
hacs.core.lovelace_mode,
use_cache,
)
hass.http.register_static_path(
URL_BASE,
hass.config.path("www/community"),
cache_headers=use_cache,
)
hacs.frontend_version = (
FE_VERSION if not hacs.configuration.experimental else EXPERIMENTAL_FE_VERSION
)
# Add to sidepanel if needed
if DOMAIN not in hass.data.get("frontend_panels", {}):
hass.components.frontend.async_register_built_in_panel(
component_name="custom",
sidebar_title=hacs.configuration.sidepanel_title,
sidebar_icon=hacs.configuration.sidepanel_icon,
frontend_url_path=DOMAIN,
config={
"_panel_custom": {
"name": "hacs-frontend",
"embed_iframe": True,
"trust_external": False,
"js_url": f"/hacsfiles/frontend/entrypoint.js?hacstag={hacs.frontend_version}",
}
},
require_admin=True,
)
class HacsFrontendDev(HomeAssistantView):
"""Dev View Class for HACS."""
requires_auth = False
name = "hacs_files:frontend"
url = r"/hacsfiles/frontend/{requested_file:.+}"
async def get(self, request, requested_file): # pylint: disable=unused-argument
"""Handle HACS Web requests."""
hacs: HacsBase = request.app["hass"].data.get(DOMAIN)
requested = requested_file.split("/")[-1]
request = await hacs.session.get(f"{hacs.configuration.frontend_repo_url}/{requested}")
if request.status == 200:
result = await request.read()
response = web.Response(body=result)
response.headers["Content-Type"] = "application/javascript"
return response
@@ -0,0 +1,5 @@
"""HACS Frontend"""
from .version import VERSION
def locate_dir():
return __path__[0]
File diff suppressed because one or more lines are too long
@@ -0,0 +1,23 @@
import{a as t,r as i,n as a}from"./main-ad130be7.js";import{L as n,s}from"./c.82eccc94.js";let r=t([a("ha-list-item")],(function(t,a){return{F:class extends a{constructor(...i){super(...i),t(this)}},d:[{kind:"get",static:!0,key:"styles",value:function(){return[s,i`
:host {
padding-left: var(--mdc-list-side-padding, 20px);
padding-right: var(--mdc-list-side-padding, 20px);
}
:host([graphic="avatar"]:not([twoLine])),
:host([graphic="icon"]:not([twoLine])) {
height: 48px;
}
span.material-icons:first-of-type {
margin-inline-start: 0px !important;
margin-inline-end: var(
--mdc-list-item-graphic-margin,
16px
) !important;
direction: var(--direction);
}
span.material-icons:last-of-type {
margin-inline-start: auto !important;
margin-inline-end: 0px !important;
direction: var(--direction);
}
`]}}]}}),n);const e=t=>`https://brands.home-assistant.io/${t.useFallback?"_/":""}${t.domain}/${t.darkOptimized?"dark_":""}${t.type}.png`,o=t=>t.split("/")[4],p=t=>t.startsWith("https://brands.home-assistant.io/");export{r as H,e as b,o as e,p as i};
@@ -0,0 +1,24 @@
import{a as e,h as t,Y as i,e as n,i as o,$ as r,L as l,N as a,r as d,n as s}from"./main-ad130be7.js";import"./c.9b92f489.js";e([s("ha-button-menu")],(function(e,t){class s extends t{constructor(...t){super(...t),e(this)}}return{F:s,d:[{kind:"field",key:i,value:void 0},{kind:"field",decorators:[n()],key:"corner",value:()=>"TOP_START"},{kind:"field",decorators:[n()],key:"menuCorner",value:()=>"START"},{kind:"field",decorators:[n({type:Number})],key:"x",value:()=>null},{kind:"field",decorators:[n({type:Number})],key:"y",value:()=>null},{kind:"field",decorators:[n({type:Boolean})],key:"multi",value:()=>!1},{kind:"field",decorators:[n({type:Boolean})],key:"activatable",value:()=>!1},{kind:"field",decorators:[n({type:Boolean})],key:"disabled",value:()=>!1},{kind:"field",decorators:[n({type:Boolean})],key:"fixed",value:()=>!1},{kind:"field",decorators:[o("mwc-menu",!0)],key:"_menu",value:void 0},{kind:"get",key:"items",value:function(){var e;return null===(e=this._menu)||void 0===e?void 0:e.items}},{kind:"get",key:"selected",value:function(){var e;return null===(e=this._menu)||void 0===e?void 0:e.selected}},{kind:"method",key:"focus",value:function(){var e,t;null!==(e=this._menu)&&void 0!==e&&e.open?this._menu.focusItemAtIndex(0):null===(t=this._triggerButton)||void 0===t||t.focus()}},{kind:"method",key:"render",value:function(){return r`
<div @click=${this._handleClick}>
<slot name="trigger" @slotchange=${this._setTriggerAria}></slot>
</div>
<mwc-menu
.corner=${this.corner}
.menuCorner=${this.menuCorner}
.fixed=${this.fixed}
.multi=${this.multi}
.activatable=${this.activatable}
.y=${this.y}
.x=${this.x}
>
<slot></slot>
</mwc-menu>
`}},{kind:"method",key:"firstUpdated",value:function(e){l(a(s.prototype),"firstUpdated",this).call(this,e),"rtl"===document.dir&&this.updateComplete.then((()=>{this.querySelectorAll("mwc-list-item").forEach((e=>{const t=document.createElement("style");t.innerHTML="span.material-icons:first-of-type { margin-left: var(--mdc-list-item-graphic-margin, 32px) !important; margin-right: 0px !important;}",e.shadowRoot.appendChild(t)}))}))}},{kind:"method",key:"_handleClick",value:function(){this.disabled||(this._menu.anchor=this,this._menu.show())}},{kind:"get",key:"_triggerButton",value:function(){return this.querySelector('ha-icon-button[slot="trigger"], mwc-button[slot="trigger"]')}},{kind:"method",key:"_setTriggerAria",value:function(){this._triggerButton&&(this._triggerButton.ariaHasPopup="menu")}},{kind:"get",static:!0,key:"styles",value:function(){return d`
:host {
display: inline-block;
position: relative;
}
::slotted([disabled]) {
color: var(--disabled-text-color);
}
`}}]}}),t);
@@ -0,0 +1,390 @@
import{a as e,h as t,e as i,g as a,t as s,$ as o,j as r,R as n,w as l,r as h,n as c,m as d,L as p,N as u,o as v,b as f,aI as b,ai as m,c as k,E as g,aJ as y,aC as w,aK as x,aL as $,d as _,s as R}from"./main-ad130be7.js";import{f as z}from"./c.3243a8b0.js";import{c as j}from"./c.4a97632a.js";import"./c.f1291e50.js";import"./c.2d5ed670.js";import"./c.97b7c4b0.js";import{r as F}from"./c.4204ca09.js";import{i as P}from"./c.21c042d4.js";import{s as I}from"./c.2645c235.js";import"./c.a5f69ed4.js";import"./c.3f859915.js";import"./c.9b92f489.js";import"./c.82eccc94.js";import"./c.8e28b461.js";import"./c.4feb0cb8.js";import"./c.0ca5587f.js";import"./c.5d3ce9d6.js";import"./c.f6611997.js";import"./c.743a15a1.js";import"./c.4266acdb.js";e([c("ha-tab")],(function(e,t){return{F:class extends t{constructor(...t){super(...t),e(this)}},d:[{kind:"field",decorators:[i({type:Boolean,reflect:!0})],key:"active",value:()=>!1},{kind:"field",decorators:[i({type:Boolean,reflect:!0})],key:"narrow",value:()=>!1},{kind:"field",decorators:[i()],key:"name",value:void 0},{kind:"field",decorators:[a("mwc-ripple")],key:"_ripple",value:void 0},{kind:"field",decorators:[s()],key:"_shouldRenderRipple",value:()=>!1},{kind:"method",key:"render",value:function(){return o`
<div
tabindex="0"
role="tab"
aria-selected=${this.active}
aria-label=${r(this.name)}
@focus=${this.handleRippleFocus}
@blur=${this.handleRippleBlur}
@mousedown=${this.handleRippleActivate}
@mouseup=${this.handleRippleDeactivate}
@mouseenter=${this.handleRippleMouseEnter}
@mouseleave=${this.handleRippleMouseLeave}
@touchstart=${this.handleRippleActivate}
@touchend=${this.handleRippleDeactivate}
@touchcancel=${this.handleRippleDeactivate}
@keydown=${this._handleKeyDown}
>
${this.narrow?o`<slot name="icon"></slot>`:""}
<span class="name">${this.name}</span>
${this._shouldRenderRipple?o`<mwc-ripple></mwc-ripple>`:""}
</div>
`}},{kind:"field",key:"_rippleHandlers",value(){return new n((()=>(this._shouldRenderRipple=!0,this._ripple)))}},{kind:"method",key:"_handleKeyDown",value:function(e){13===e.keyCode&&e.target.click()}},{kind:"method",decorators:[l({passive:!0})],key:"handleRippleActivate",value:function(e){this._rippleHandlers.startPress(e)}},{kind:"method",key:"handleRippleDeactivate",value:function(){this._rippleHandlers.endPress()}},{kind:"method",key:"handleRippleMouseEnter",value:function(){this._rippleHandlers.startHover()}},{kind:"method",key:"handleRippleMouseLeave",value:function(){this._rippleHandlers.endHover()}},{kind:"method",key:"handleRippleFocus",value:function(){this._rippleHandlers.startFocus()}},{kind:"method",key:"handleRippleBlur",value:function(){this._rippleHandlers.endFocus()}},{kind:"get",static:!0,key:"styles",value:function(){return h`
div {
padding: 0 32px;
display: flex;
flex-direction: column;
text-align: center;
box-sizing: border-box;
align-items: center;
justify-content: center;
width: 100%;
height: var(--header-height);
cursor: pointer;
position: relative;
outline: none;
}
.name {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 100%;
}
:host([active]) {
color: var(--primary-color);
}
:host(:not([narrow])[active]) div {
border-bottom: 2px solid var(--primary-color);
}
:host([narrow]) {
min-width: 0;
display: flex;
justify-content: center;
overflow: hidden;
}
:host([narrow]) div {
padding: 0 4px;
}
`}}]}}),t),e([c("hass-tabs-subpage")],(function(e,t){class a extends t{constructor(...t){super(...t),e(this)}}return{F:a,d:[{kind:"field",decorators:[i({attribute:!1})],key:"hass",value:void 0},{kind:"field",decorators:[i({type:Boolean})],key:"supervisor",value:()=>!1},{kind:"field",decorators:[i({attribute:!1})],key:"localizeFunc",value:void 0},{kind:"field",decorators:[i({type:String,attribute:"back-path"})],key:"backPath",value:void 0},{kind:"field",decorators:[i()],key:"backCallback",value:void 0},{kind:"field",decorators:[i({type:Boolean,attribute:"main-page"})],key:"mainPage",value:()=>!1},{kind:"field",decorators:[i({attribute:!1})],key:"route",value:void 0},{kind:"field",decorators:[i({attribute:!1})],key:"tabs",value:void 0},{kind:"field",decorators:[i({type:Boolean,reflect:!0})],key:"narrow",value:()=>!1},{kind:"field",decorators:[i({type:Boolean,reflect:!0,attribute:"is-wide"})],key:"isWide",value:()=>!1},{kind:"field",decorators:[i({type:Boolean,reflect:!0})],key:"rtl",value:()=>!1},{kind:"field",decorators:[s()],key:"_activeTab",value:void 0},{kind:"field",decorators:[F(".content")],key:"_savedScrollPos",value:void 0},{kind:"field",key:"_getTabs",value(){return d(((e,t,i,a,s,r,n)=>{const l=e.filter((e=>(!e.component||e.core||P(this.hass,e.component))&&(!e.advancedOnly||i)));if(l.length<2){if(1===l.length){const e=l[0];return[e.translationKey?n(e.translationKey):e.name]}return[""]}return l.map((e=>o`
<a href=${e.path}>
<ha-tab
.hass=${this.hass}
.active=${e.path===(null==t?void 0:t.path)}
.narrow=${this.narrow}
.name=${e.translationKey?n(e.translationKey):e.name}
>
${e.iconPath?o`<ha-svg-icon
slot="icon"
.path=${e.iconPath}
></ha-svg-icon>`:""}
</ha-tab>
</a>
`))}))}},{kind:"method",key:"willUpdate",value:function(e){if(e.has("route")&&(this._activeTab=this.tabs.find((e=>`${this.route.prefix}${this.route.path}`.includes(e.path)))),e.has("hass")){const t=e.get("hass");t&&t.language===this.hass.language||(this.rtl=j(this.hass))}p(u(a.prototype),"willUpdate",this).call(this,e)}},{kind:"method",key:"render",value:function(){var e,t;const i=this._getTabs(this.tabs,this._activeTab,null===(e=this.hass.userData)||void 0===e?void 0:e.showAdvanced,this.hass.config.components,this.hass.language,this.narrow,this.localizeFunc||this.hass.localize),a=i.length>1;return o`
<div class="toolbar">
${this.mainPage||!this.backPath&&null!==(t=history.state)&&void 0!==t&&t.root?o`
<ha-menu-button
.hassio=${this.supervisor}
.hass=${this.hass}
.narrow=${this.narrow}
></ha-menu-button>
`:this.backPath?o`
<a href=${this.backPath}>
<ha-icon-button-arrow-prev
.hass=${this.hass}
></ha-icon-button-arrow-prev>
</a>
`:o`
<ha-icon-button-arrow-prev
.hass=${this.hass}
@click=${this._backTapped}
></ha-icon-button-arrow-prev>
`}
${this.narrow||!a?o`<div class="main-title">
<slot name="header">${a?"":i[0]}</slot>
</div>`:""}
${a?o`
<div id="tabbar" class=${v({"bottom-bar":this.narrow})}>
${i}
</div>
`:""}
<div id="toolbar-icon">
<slot name="toolbar-icon"></slot>
</div>
</div>
<div
class="content ${v({tabs:a})}"
@scroll=${this._saveScrollPos}
>
<slot></slot>
</div>
<div id="fab" class=${v({tabs:a})}>
<slot name="fab"></slot>
</div>
`}},{kind:"method",decorators:[l({passive:!0})],key:"_saveScrollPos",value:function(e){this._savedScrollPos=e.target.scrollTop}},{kind:"method",key:"_backTapped",value:function(){this.backCallback?this.backCallback():history.back()}},{kind:"get",static:!0,key:"styles",value:function(){return h`
:host {
display: block;
height: 100%;
background-color: var(--primary-background-color);
}
:host([narrow]) {
width: 100%;
position: fixed;
}
ha-menu-button {
margin-right: 24px;
}
.toolbar {
display: flex;
align-items: center;
font-size: 20px;
height: var(--header-height);
background-color: var(--sidebar-background-color);
font-weight: 400;
border-bottom: 1px solid var(--divider-color);
padding: 0 16px;
box-sizing: border-box;
}
.toolbar a {
color: var(--sidebar-text-color);
text-decoration: none;
}
.bottom-bar a {
width: 25%;
}
#tabbar {
display: flex;
font-size: 14px;
overflow: hidden;
}
#tabbar > a {
overflow: hidden;
max-width: 45%;
}
#tabbar.bottom-bar {
position: absolute;
bottom: 0;
left: 0;
padding: 0 16px;
box-sizing: border-box;
background-color: var(--sidebar-background-color);
border-top: 1px solid var(--divider-color);
justify-content: space-around;
z-index: 2;
font-size: 12px;
width: 100%;
padding-bottom: env(safe-area-inset-bottom);
}
#tabbar:not(.bottom-bar) {
flex: 1;
justify-content: center;
}
:host(:not([narrow])) #toolbar-icon {
min-width: 40px;
}
ha-menu-button,
ha-icon-button-arrow-prev,
::slotted([slot="toolbar-icon"]) {
display: flex;
flex-shrink: 0;
pointer-events: auto;
color: var(--sidebar-icon-color);
}
.main-title {
flex: 1;
max-height: var(--header-height);
line-height: 20px;
color: var(--sidebar-text-color);
margin: var(--main-title-margin, 0 0 0 24px);
}
.content {
position: relative;
width: calc(
100% - env(safe-area-inset-left) - env(safe-area-inset-right)
);
margin-left: env(safe-area-inset-left);
margin-right: env(safe-area-inset-right);
height: calc(100% - 1px - var(--header-height));
height: calc(
100% - 1px - var(--header-height) - env(safe-area-inset-bottom)
);
overflow: auto;
-webkit-overflow-scrolling: touch;
}
:host([narrow]) .content.tabs {
height: calc(100% - 2 * var(--header-height));
height: calc(
100% - 2 * var(--header-height) - env(safe-area-inset-bottom)
);
}
#fab {
position: fixed;
right: calc(16px + env(safe-area-inset-right));
bottom: calc(16px + env(safe-area-inset-bottom));
z-index: 1;
}
:host([narrow]) #fab.tabs {
bottom: calc(84px + env(safe-area-inset-bottom));
}
#fab[is-wide] {
bottom: 24px;
right: 24px;
}
:host([rtl]) #fab {
right: auto;
left: calc(16px + env(safe-area-inset-left));
}
:host([rtl][is-wide]) #fab {
bottom: 24px;
left: 24px;
right: auto;
}
`}}]}}),t);let E=e([c("hacs-store-panel")],(function(e,t){return{F:class extends t{constructor(...t){super(...t),e(this)}},d:[{kind:"field",decorators:[i({attribute:!1})],key:"filters",value:()=>({})},{kind:"field",decorators:[i({attribute:!1})],key:"hacs",value:void 0},{kind:"field",decorators:[i()],key:"_searchInput",value:()=>""},{kind:"field",decorators:[i({attribute:!1})],key:"hass",value:void 0},{kind:"field",decorators:[i({attribute:!1})],key:"narrow",value:void 0},{kind:"field",decorators:[i({attribute:!1})],key:"isWide",value:void 0},{kind:"field",decorators:[i({attribute:!1})],key:"route",value:void 0},{kind:"field",decorators:[i({attribute:!1})],key:"sections",value:void 0},{kind:"field",decorators:[i()],key:"section",value:void 0},{kind:"field",key:"_repositoriesInActiveSection",value(){return d(((e,t)=>[(null==e?void 0:e.filter((e=>{var i,a,s;return(null===(i=this.hacs.sections)||void 0===i||null===(a=i.find((e=>e.id===t)))||void 0===a||null===(s=a.categories)||void 0===s?void 0:s.includes(e.category))&&e.installed})))||[],(null==e?void 0:e.filter((e=>{var i,a,s;return(null===(i=this.hacs.sections)||void 0===i||null===(a=i.find((e=>e.id===t)))||void 0===a||null===(s=a.categories)||void 0===s?void 0:s.includes(e.category))&&e.new&&!e.installed})))||[]]))}},{kind:"get",key:"allRepositories",value:function(){const[e,t]=this._repositoriesInActiveSection(this.hacs.repositories,this.section);return t.concat(e)}},{kind:"field",key:"_filterRepositories",value:()=>d(z)},{kind:"get",key:"visibleRepositories",value:function(){const e=this.allRepositories.filter((e=>{var t,i;return null===(t=this.filters[this.section])||void 0===t||null===(i=t.find((t=>t.id===e.category)))||void 0===i?void 0:i.checked}));return this._filterRepositories(e,this._searchInput)}},{kind:"method",key:"firstUpdated",value:async function(){this.addEventListener("filter-change",(e=>this._updateFilters(e)))}},{kind:"method",key:"_updateFilters",value:function(e){var t;const i=null===(t=this.filters[this.section])||void 0===t?void 0:t.find((t=>t.id===e.detail.id));this.filters[this.section].find((e=>e.id===i.id)).checked=!i.checked,this.requestUpdate()}},{kind:"method",key:"render",value:function(){var e;if(!this.hacs)return o``;const t=this._repositoriesInActiveSection(this.hacs.repositories,this.section)[1];if(!this.filters[this.section]&&this.hacs.info.categories){var i;const e=null===(i=f(this.hacs.language,this.route))||void 0===i?void 0:i.categories;this.filters[this.section]=[],null==e||e.filter((e=>{var t;return null===(t=this.hacs.info)||void 0===t?void 0:t.categories.includes(e)})).forEach((e=>{this.filters[this.section].push({id:e,value:e,checked:!0})}))}return o`<hass-tabs-subpage
back-path="/hacs/entry"
.hass=${this.hass}
.narrow=${this.narrow}
.route=${this.route}
.tabs=${this.hacs.sections}
hasFab
>
<ha-icon-overflow-menu
slot="toolbar-icon"
narrow
.hass=${this.hass}
.items=${[{path:b,label:this.hacs.localize("menu.documentation"),action:()=>m.open("https://hacs.xyz/","_blank","noreferrer=true")},{path:k,label:"GitHub",action:()=>m.open("https://github.com/hacs","_blank","noreferrer=true")},{path:g,label:this.hacs.localize("menu.open_issue"),action:()=>m.open("https://hacs.xyz/docs/issues","_blank","noreferrer=true")},{path:y,label:this.hacs.localize("menu.custom_repositories"),disabled:this.hacs.info.disabled_reason,action:()=>this.dispatchEvent(new CustomEvent("hacs-dialog",{detail:{type:"custom-repositories",repositories:this.hacs.repositories},bubbles:!0,composed:!0}))},{path:w,label:this.hacs.localize("menu.about"),action:()=>I(this,this.hacs)}]}
>
</ha-icon-overflow-menu>
${this.narrow?o`
<search-input
.hass=${this.hass}
class="header"
slot="header"
.label=${this.hacs.localize("search.downloaded")}
.filter=${this._searchInput||""}
@value-changed=${this._inputValueChanged}
></search-input>
`:o`<div class="search">
<search-input
.hass=${this.hass}
.label=${0===t.length?this.hacs.localize("search.downloaded"):this.hacs.localize("search.downloaded_new")}
.filter=${this._searchInput||""}
@value-changed=${this._inputValueChanged}
></search-input>
</div>`}
<div class="content ${this.narrow?"narrow-content":""}">
${(null===(e=this.filters[this.section])||void 0===e?void 0:e.length)>1?o`<div class="filters">
<hacs-filter
.hacs=${this.hacs}
.filters="${this.filters[this.section]}"
></hacs-filter>
</div>`:""}
${null!=t&&t.length?o`<ha-alert .rtl=${j(this.hass)}>
${this.hacs.localize("store.new_repositories_note")}
<mwc-button
class="max-content"
slot="action"
.label=${this.hacs.localize("menu.dismiss")}
@click=${this._clearAllNewRepositories}
>
</mwc-button>
</ha-alert> `:""}
<div class="container ${this.narrow?"narrow":""}">
${void 0===this.hacs.repositories?"":0===this.allRepositories.length?this._renderEmpty():0===this.visibleRepositories.length?this._renderNoResultsFound():this._renderRepositories()}
</div>
</div>
<ha-fab
slot="fab"
.label=${this.hacs.localize("store.explore")}
.extended=${!this.narrow}
@click=${this._addRepository}
>
<ha-svg-icon slot="icon" .path=${x}></ha-svg-icon>
</ha-fab>
</hass-tabs-subpage>`}},{kind:"method",key:"_renderRepositories",value:function(){return this.visibleRepositories.map((e=>o`<hacs-repository-card
.hass=${this.hass}
.hacs=${this.hacs}
.repository=${e}
.narrow=${this.narrow}
?narrow=${this.narrow}
></hacs-repository-card>`))}},{kind:"method",key:"_clearAllNewRepositories",value:async function(){var e;await $(this.hass,{categories:(null===(e=f(this.hacs.language,this.route))||void 0===e?void 0:e.categories)||[]})}},{kind:"method",key:"_renderNoResultsFound",value:function(){return o`<ha-alert
.rtl=${j(this.hass)}
alert-type="warning"
.title="${this.hacs.localize("store.no_repositories")} 😕"
>
${this.hacs.localize("store.no_repositories_found_desc1",{searchInput:this._searchInput})}
<br />
${this.hacs.localize("store.no_repositories_found_desc2")}
</ha-alert>`}},{kind:"method",key:"_renderEmpty",value:function(){return o`<ha-alert
.title="${this.hacs.localize("store.no_repositories")} 😕"
.rtl=${j(this.hass)}
>
${this.hacs.localize("store.no_repositories_desc1")}
<br />
${this.hacs.localize("store.no_repositories_desc2")}
</ha-alert>`}},{kind:"method",key:"_inputValueChanged",value:function(e){this._searchInput=e.detail.value,window.localStorage.setItem("hacs-search",this._searchInput)}},{kind:"method",key:"_addRepository",value:function(){this.dispatchEvent(new CustomEvent("hacs-dialog",{detail:{type:"add-repository",repositories:this.hacs.repositories,section:this.section},bubbles:!0,composed:!0}))}},{kind:"get",static:!0,key:"styles",value:function(){return[_,R,h`
.filter {
border-bottom: 1px solid var(--divider-color);
}
.content {
height: calc(100vh - 128px);
overflow: auto;
}
.narrow-content {
height: calc(100vh - 128px);
}
.container {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(480px, 1fr));
justify-items: center;
grid-gap: 8px 8px;
padding: 8px 16px 16px;
margin-bottom: 64px;
}
ha-svg-icon {
color: var(--hcv-text-color-on-background);
}
hacs-repository-card {
max-width: 500px;
display: flex;
flex-direction: column;
justify-content: space-between;
}
hacs-repository-card[narrow] {
width: 100%;
}
hacs-repository-card[narrow]:last-of-type {
margin-bottom: 64px;
}
ha-alert {
color: var(--hcv-text-color-primary);
display: block;
margin-top: -4px;
}
.narrow {
width: 100%;
display: block;
padding: 0px;
margin: 0;
}
search-input {
display: block;
}
search-input.header {
padding: 0;
}
.bottom-bar {
position: fixed !important;
}
.max-content {
width: max-content;
}
`]}}]}}),t);export{E as HacsStorePanel};
File diff suppressed because one or more lines are too long

Some files were not shown because too many files have changed in this diff Show More