first commit
This commit is contained in:
@@ -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
|
||||
|
||||
%:
|
||||
@:
|
||||
@@ -0,0 +1,4 @@
|
||||
# home-assistant-alt
|
||||
|
||||
|
||||
"Alternate" home-assistant configurateion (other flat).
|
||||
@@ -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
+1
Submodule appdaemon/docker/emp_mos_api added at 8354c40657
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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))
|
||||
@@ -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
|
||||
@@ -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")
|
||||
@@ -0,0 +1,5 @@
|
||||
test:
|
||||
module: test
|
||||
class: Test
|
||||
dependencies:
|
||||
- notify
|
||||
Executable
+14
@@ -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)
|
||||
Executable
@@ -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"
|
||||
@@ -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"
|
||||
Executable
+6
@@ -0,0 +1,6 @@
|
||||
#!/bin/sh
|
||||
while [ 1 ];
|
||||
do
|
||||
certbot renew --agree-tos --standalone
|
||||
sleep 1d
|
||||
done
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
Binary file not shown.
@@ -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};
|
||||
Binary file not shown.
@@ -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);
|
||||
Binary file not shown.
@@ -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};
|
||||
Binary file not shown.
File diff suppressed because one or more lines are too long
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user