Compare commits
343 Commits
c31c5f8c2b
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 085fa659f9 | |||
| 70b0a4d8a5 | |||
| 36bb43728d | |||
| 3c704d0fbc | |||
| b29d58748b | |||
| 66b5f5c8b8 | |||
| 572e331147 | |||
| d0439f2d78 | |||
| b02e3c0ac7 | |||
| 97d51536a9 | |||
| 39ba9e4ddf | |||
| 443d12c4dc | |||
| dc3392db6f | |||
| acfcbc1ee7 | |||
| 2decb4cf94 | |||
| 5d677c5583 | |||
| a34278cef4 | |||
| b097fcb933 | |||
| d7a4640cf7 | |||
| 05b4bedc07 | |||
| d73369842f | |||
| de6a22dcf0 | |||
| facde37e2d | |||
| 4c6af73504 | |||
| 0e6b76e35a | |||
| 4dc4f93453 | |||
| eb8f59060e | |||
| 9a5366c959 | |||
| 25c3a5838e | |||
| a3088d1993 | |||
| bb7af1a4f9 | |||
| 6eb13f90e3 | |||
| 666cbb14a1 | |||
| 0d85635345 | |||
| 46a578137d | |||
| fc250d4c58 | |||
| 4bb1ee483a | |||
| ceea4d6729 | |||
| af705c019e | |||
| 358622b374 | |||
| 1672388604 | |||
| 9cba82fe6b | |||
| 4bd1eeca00 | |||
| d32dfcebcb | |||
| 22e4607716 | |||
| 08816f2b9b | |||
| 784fead4aa | |||
| 16c63ee7c1 | |||
| 3c94dd1007 | |||
| f1d03ca398 | |||
| 202a4395da | |||
| 3da40af450 | |||
| c7042584f7 | |||
| 15718a2bb3 | |||
| 3c55adf23e | |||
| 0610c8fdd3 | |||
| 5b738a7853 | |||
| 0a8d1ee4bd | |||
| 59dc066efc | |||
| 485fb3be32 | |||
| 3361337966 | |||
| 5e20ccfef8 | |||
| 4885f3d993 | |||
| 48d56ff832 | |||
| f698ca4575 | |||
| f21c85d996 | |||
| 2665413b60 | |||
| 1b64ea6cc9 | |||
| 39f2a22cc5 | |||
| f9b63a427a | |||
| 98abb037a9 | |||
| 670a125b8f | |||
| 195dc27e29 | |||
| 6dbd89b476 | |||
| a308a9d423 | |||
| efe9d73117 | |||
| 1a1e95803f | |||
| d0c31ac39e | |||
| b9699b4dfd | |||
| ed36ab28b8 | |||
| 456d18d969 | |||
| fdbe8ecb11 | |||
| 9775011c23 | |||
| cc72610f05 | |||
| 1a1e416e15 | |||
| 2488c9965d | |||
| 9db36a217d | |||
| 3cd183c32e | |||
| 212adaabef | |||
| c1d8dae4e4 | |||
| 2c52a941a5 | |||
| 84218ee788 | |||
| b0e1d75efc | |||
| d0c44d4cc9 | |||
| 1bfc33084a | |||
| 33966c8c51 | |||
| f71a709f7f | |||
| 5a9f690552 | |||
| 2d7eae9419 | |||
|
|
b8fc375552 | ||
| efe293b40b | |||
| 63e5007232 | |||
| c269335a66 | |||
| 556633db8f | |||
| 7b32d96611 | |||
| e7c5e543b4 | |||
| fe51dac8b4 | |||
| e3a69f28fc | |||
| cb0a8c73f1 | |||
| daad0f6553 | |||
| c9d168ca5b | |||
| 1b631ca798 | |||
| f59daeee1c | |||
| 4928faf744 | |||
| 58200ceec2 | |||
| 996e8c11c9 | |||
| 97d44d93f9 | |||
| 996aa0b9f3 | |||
| 5a9e5cce3a | |||
| 3e64835a30 | |||
| 0a563f653f | |||
| 5d95452c5b | |||
| b036708350 | |||
| b9e1fff69f | |||
|
|
df90dcc6cb | ||
| 80dbf737ee | |||
| 2910e239c5 | |||
| f8132fb67f | |||
| 5e61b25d28 | |||
| 74c45ae6a2 | |||
| 0a043443d9 | |||
| 156d1b7123 | |||
| e90a172d9f | |||
| c1a455fe5d | |||
| 469e52dd33 | |||
| 9ab9840395 | |||
| ea0d96503f | |||
| 2dcb9d2300 | |||
| 895c881bb5 | |||
|
|
4d6665c472 | ||
|
|
8056390e49 | ||
| 5d28b2c4ee | |||
| 39fb3ccd1d | |||
| f659406986 | |||
| 0795ee10f7 | |||
| 6faef407df | |||
| d5b4b832a3 | |||
| d8cea6916a | |||
| c1ce7e0fc9 | |||
| 2736246bc6 | |||
| 2e3e8ff5d2 | |||
| 8c23240a66 | |||
| 82ea7d6c42 | |||
| 0e4f8703d8 | |||
| aae5a70261 | |||
| 722c64072b | |||
| 09b3b0adca | |||
| 326f99a5e8 | |||
| 6b27e19e2d | |||
| 23b5852b46 | |||
| 77fb36c9a0 | |||
| 4c49804e5e | |||
|
|
b873ba0ef0 | ||
| 7c560d76e4 | |||
| 5722ed52e6 | |||
| f29b58be9a | |||
| ee48bff488 | |||
| 62013c02c3 | |||
| 1fad220911 | |||
| 4c1c7dec90 | |||
| 9c0ce83e02 | |||
| eb308e9cff | |||
| deb84217e3 | |||
| 387a240d31 | |||
| fa4047ad7c | |||
| b32ff66572 | |||
| 003fe98ecf | |||
| 3e71db7665 | |||
| 229e2ba82f | |||
| d1591f7a2a | |||
| 41e84335d5 | |||
| e234f0ffe7 | |||
| 7745da43fd | |||
| 47290b03a5 | |||
| c6a41814a3 | |||
| 5899df15fc | |||
| 3442af63b0 | |||
| 61dec6c4d6 | |||
| 201513180d | |||
| 7b81862a1a | |||
| 0b474c92fd | |||
| 26622384e7 | |||
| 6e5eddc243 | |||
|
|
108f9c24cc | ||
| 791369c66a | |||
|
|
cf09f24303 | ||
| 8d41efc3eb | |||
| 49f1db47d6 | |||
| 24d1c24b92 | |||
| de014d785d | |||
| a177a85a10 | |||
| 46019507ec | |||
| d374ab8249 | |||
| 591d484d8f | |||
| e5856e9488 | |||
| ac37a3a125 | |||
| c797f43378 | |||
| ad56dda579 | |||
| 73a6ef512f | |||
| 7ede540ae8 | |||
| 86bfdef296 | |||
| 0160866a94 | |||
| 4513653fb7 | |||
| b02fc6783a | |||
| 242b2389e5 | |||
| 530035e01a | |||
| 65d9b7ab83 | |||
| f7f0aadf21 | |||
| 24ab9bf6ec | |||
| 07f4e6b2c2 | |||
| 142adbc036 | |||
| 88431c3d8c | |||
| aff800904d | |||
| e347e74505 | |||
| 6e65862bc2 | |||
| dbebdeadc1 | |||
| deafee5faa | |||
| 4ff27b23df | |||
| f105b0d902 | |||
| 70d856c92e | |||
| bc22e2d5a7 | |||
| a8af72541d | |||
| 9323fffa5b | |||
| 28303fa0fd | |||
| bb98e94d28 | |||
| 97300b755d | |||
| beb1c164c4 | |||
| 348f607b1d | |||
| 0c37c7029d | |||
| 6cdfa36d53 | |||
| b86e1983f1 | |||
| b2446aa9ac | |||
| a993e0110f | |||
| 668eeb0d15 | |||
| 0b53292b04 | |||
| 4eaa6055b9 | |||
| 2cfe5288de | |||
| 829a6f2cf4 | |||
| a765f93b7d | |||
| ad3f691f84 | |||
| 5ee48054dd | |||
| 2d19d2249b | |||
| c0943b69b8 | |||
| c15f4e8ec4 | |||
| 64d015b4bc | |||
| d459d808dc | |||
| f95fffe056 | |||
| 57af7235dc | |||
| bed51650a0 | |||
| 4ef3c20c70 | |||
| 1e4192c206 | |||
| b400486526 | |||
| 38176c839b | |||
| a4e9c14709 | |||
| 71bd2e796e | |||
| f7a1292526 | |||
| 205e5562fc | |||
| b2f7786ff5 | |||
| 91e4efd531 | |||
| 6023c81531 | |||
| f192f6a45f | |||
| 7b0386798e | |||
| 2102bcf67c | |||
| 87a8791001 | |||
| 04ea888ec2 | |||
| 56fa04ffc7 | |||
| 2eef1192ed | |||
| 1fef521fbd | |||
| ed91ffe899 | |||
| 90322fde2a | |||
| 3071c96a9e | |||
| 4a00e1f036 | |||
| c0d48c220e | |||
| 1fbfcd49c7 | |||
| 1833c91d0e | |||
| 73460d5c3c | |||
| 4981bb797a | |||
| 4ca66c4186 | |||
| 39ddea7900 | |||
| 233ac972a7 | |||
| 53d06da51b | |||
|
|
2d1e374e3a | ||
| 1970e01e48 | |||
| 2b55d0b167 | |||
| 53351f865f | |||
| 83e7352547 | |||
| 97de261573 | |||
| 81f8f5b768 | |||
| bd8b0bcefc | |||
| cd794a8e3c | |||
| 4cdded6752 | |||
| a1e6033fcd | |||
| 4065c97139 | |||
| 9c75e6b278 | |||
|
|
c91b0de496 | ||
|
|
bb67b3d22b | ||
| 5eca7623dd | |||
| ac8cf8f906 | |||
| 6543f8dfeb | |||
| 0dc53bab0b | |||
| 81b4166cfe | |||
| 2bef2a1781 | |||
| e506cbbe06 | |||
| c672700323 | |||
| 98dac1c994 | |||
| 2a5fcc8af6 | |||
| b26f01d8cd | |||
| 549032857f | |||
| 7c0bd90b1f | |||
| 1735720adc | |||
| b3b12deace | |||
| 6823dc8596 | |||
| f02b8dcd31 | |||
| 815151f959 | |||
| 1bea5c4df4 | |||
| dbcef1896a | |||
| 2a59c8f937 | |||
| 7452ccefc1 | |||
| 6a0fbc2541 | |||
| 509bfa4849 | |||
| 04e1b0ecc8 | |||
| 76121c7b98 | |||
| f76c9dfba8 | |||
|
|
926459c9fd | ||
|
|
e6757e2cbd | ||
| e01a037d8d | |||
| 1dd97a14c2 | |||
| 8b53669400 | |||
| e929517799 | |||
| e157001dd9 | |||
| b0d8c661ef | |||
| 13751ee291 | |||
| 92eda1c045 |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -1,5 +1,6 @@
|
||||
.HA_VERSION
|
||||
.cloud
|
||||
.private
|
||||
.storage
|
||||
.uuid
|
||||
*.conf
|
||||
@@ -7,9 +8,11 @@
|
||||
*.crt
|
||||
*.key
|
||||
*.tar.gz
|
||||
*.pyc
|
||||
adb/
|
||||
deps/
|
||||
tts/
|
||||
www/
|
||||
__pycache__/
|
||||
blueprints/
|
||||
custom_components/
|
||||
|
||||
@@ -5,13 +5,14 @@ homeassistant:
|
||||
name: Home
|
||||
|
||||
# Location and Time Zone
|
||||
country: US
|
||||
latitude: !secret home_latitude # dummy: 0.0
|
||||
longitude: !secret home_longitude # dummy: 0.0
|
||||
elevation: 10 # meters, WGS84
|
||||
time_zone: America/Los_Angeles
|
||||
|
||||
# Measurement System
|
||||
unit_system: imperial
|
||||
unit_system: us_customary
|
||||
currency: USD
|
||||
|
||||
# URL and Access Control
|
||||
@@ -31,6 +32,19 @@ homeassistant:
|
||||
customize: !include include/customize.yaml
|
||||
packages: !include_dir_named packages
|
||||
|
||||
# OpenID Connect
|
||||
auth_oidc:
|
||||
display_name: KraussNet SSO
|
||||
client_id: "hass"
|
||||
discovery_url: "https://idm.kraussnet.com/oauth2/openid/hass/.well-known/openid-configuration"
|
||||
features:
|
||||
automatic_person_creation: false
|
||||
automatic_user_linking: true
|
||||
id_token_signing_alg: "ES256"
|
||||
roles:
|
||||
admin: "hass_admins@idm.kraussnet.com"
|
||||
user: "hass_users@idm.kraussnet.com"
|
||||
|
||||
# HTTP Access
|
||||
http:
|
||||
ip_ban_enabled: true
|
||||
@@ -42,14 +56,13 @@ http:
|
||||
- !secret gateway_ip4
|
||||
|
||||
# Configure Lovelace/Dashboards for YAML Configuration
|
||||
# lovelace:
|
||||
# mode: yaml
|
||||
# resources: !include lovelace/resources.yaml
|
||||
# dashboards: !include lovelace/dashboards.yaml
|
||||
lovelace:
|
||||
mode: yaml
|
||||
resources: !include lovelace/resources.yaml
|
||||
dashboards: !include lovelace/dashboards.yaml
|
||||
|
||||
# MQTT Broker Connection
|
||||
mqtt:
|
||||
certificate: /config/network-ca-chain.pem
|
||||
|
||||
# Configure Logging
|
||||
logger:
|
||||
@@ -65,9 +78,17 @@ logger:
|
||||
# Enable the Configuration UI
|
||||
config:
|
||||
|
||||
# Enable the Energy Dashboard
|
||||
energy:
|
||||
|
||||
# Enable FFmpeg for Streaming
|
||||
ffmpeg:
|
||||
|
||||
# Enable the Front End
|
||||
frontend:
|
||||
themes: !include_dir_merge_named themes
|
||||
extra_module_url:
|
||||
- /hacsfiles/lovelace-card-mod/card-mod.js
|
||||
|
||||
# Configure FontAwesome Icons
|
||||
fontawesome:
|
||||
@@ -81,7 +102,8 @@ history:
|
||||
logbook:
|
||||
|
||||
# Enables a map showing the location of tracked devices
|
||||
map:
|
||||
# NOTE: Removed from YAML in 2024.10.0
|
||||
# map:
|
||||
|
||||
# Enable the Home Assistant Companion Apps
|
||||
mobile_app:
|
||||
@@ -97,6 +119,9 @@ recorder:
|
||||
# Used by Philips Hue (disable if moving to Zigbee)
|
||||
ssdp:
|
||||
|
||||
# Enable streaming support
|
||||
stream:
|
||||
|
||||
# Track the sun
|
||||
sun:
|
||||
|
||||
@@ -106,14 +131,14 @@ system_health:
|
||||
# Enable UPnP/Zeroconf Service Discovery and Advertising
|
||||
zeroconf:
|
||||
|
||||
# Discover some devices automatically
|
||||
discovery:
|
||||
# Discover some devices automatically (deprecated in 2023.8)
|
||||
# discovery:
|
||||
|
||||
# Setup Includes for UI
|
||||
automation: !include include/automations.yaml
|
||||
scene: !include include/scenes.yaml
|
||||
script: !include include/scripts.yaml
|
||||
zone: !include include/zones.yaml
|
||||
automation ui: !include automations.yaml
|
||||
scene ui: !include scenes.yaml
|
||||
script ui: !include scripts.yaml
|
||||
zone ui: !include zones.yaml
|
||||
|
||||
# Setup InfluxDB Logging
|
||||
influxdb:
|
||||
@@ -141,3 +166,12 @@ influxdb:
|
||||
- script
|
||||
- weather
|
||||
- zone
|
||||
|
||||
# Force ZHA to use Channel 15 or 20
|
||||
zha:
|
||||
zigpy_config:
|
||||
network:
|
||||
channel: 15
|
||||
channels: [15, 20]
|
||||
ota:
|
||||
inovelli_provider: true
|
||||
|
||||
@@ -1,77 +1,57 @@
|
||||
"""
|
||||
HACS gives you a powerful UI to handle downloads of all your custom needs.
|
||||
"""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 __future__ import annotations
|
||||
|
||||
from aiogithubapi import AIOGitHubAPIException, GitHub, GitHubAPI
|
||||
from aiogithubapi.const import ACCEPT_HEADERS
|
||||
from awesomeversion import AwesomeVersion
|
||||
from homeassistant.components.frontend import async_remove_panel
|
||||
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.entity_registry import async_get as async_get_entity_registry
|
||||
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 custom_components.hacs.frontend import async_register_frontend
|
||||
|
||||
from .base import HacsBase
|
||||
from .const import DOMAIN, MINIMUM_HA_VERSION, STARTUP
|
||||
from .enums import ConfigurationType, HacsDisabledReason, HacsStage, LovelaceMode
|
||||
from .utils.configuration_schema import hacs_config_combined
|
||||
from .const import DOMAIN, HACS_SYSTEM_ID, MINIMUM_HA_VERSION, STARTUP
|
||||
from .data_client import HacsDataClient
|
||||
from .enums import HacsDisabledReason, HacsStage, LovelaceMode
|
||||
from .frontend import async_register_frontend
|
||||
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)
|
||||
PLATFORMS = [Platform.SWITCH, Platform.UPDATE]
|
||||
|
||||
|
||||
async def async_initialize_integration(
|
||||
async def _async_initialize_integration(
|
||||
hass: HomeAssistant,
|
||||
*,
|
||||
config_entry: ConfigEntry | None = None,
|
||||
config: dict[str, Any] | None = None,
|
||||
config_entry: ConfigEntry,
|
||||
) -> 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:
|
||||
# Import is not supported
|
||||
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)
|
||||
@@ -88,6 +68,10 @@ async def async_initialize_integration(
|
||||
hacs.hass = hass
|
||||
hacs.queue = QueueManager(hass=hass)
|
||||
hacs.data = HacsData(hacs=hacs)
|
||||
hacs.data_client = HacsDataClient(
|
||||
session=clientsession,
|
||||
client_name=f"HACS/{integration.version}",
|
||||
)
|
||||
hacs.system.running = True
|
||||
hacs.session = clientsession
|
||||
|
||||
@@ -98,7 +82,6 @@ async def async_initialize_integration(
|
||||
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:
|
||||
@@ -125,15 +108,14 @@ async def async_initialize_integration(
|
||||
"""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):
|
||||
try:
|
||||
import custom_components.custom_updater
|
||||
except ImportError:
|
||||
pass
|
||||
else:
|
||||
hacs.log.critical(
|
||||
"This cannot be used with custom_updater. "
|
||||
"To use this you need to remove custom_updater form %s",
|
||||
location,
|
||||
"HACS cannot be used with custom_updater. "
|
||||
"To use HACS you need to remove custom_updater from `custom_components`",
|
||||
)
|
||||
|
||||
hacs.disable_hacs(HacsDisabledReason.CONSTRAINS)
|
||||
@@ -154,40 +136,23 @@ async def async_initialize_integration(
|
||||
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)
|
||||
await 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:
|
||||
if hacs.configuration.experimental:
|
||||
hass.config_entries.async_setup_platforms(
|
||||
hacs.configuration.config_entry, [Platform.SENSOR, Platform.UPDATE]
|
||||
)
|
||||
else:
|
||||
hass.config_entries.async_setup_platforms(
|
||||
hacs.configuration.config_entry, [Platform.SENSOR]
|
||||
)
|
||||
await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS)
|
||||
|
||||
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")
|
||||
|
||||
# Schedule startup tasks
|
||||
async_at_start(hass=hass, at_start_cb=hacs.startup_tasks)
|
||||
|
||||
return not hacs.system.disabled
|
||||
|
||||
async def async_try_startup(_=None):
|
||||
@@ -197,10 +162,7 @@ async def async_initialize_integration(
|
||||
except AIOGitHubAPIException:
|
||||
startup_result = False
|
||||
if not startup_result:
|
||||
if (
|
||||
hacs.configuration.config_type == ConfigurationType.YAML
|
||||
or hacs.system.disabled_reason != HacsDisabledReason.INVALID_TOKEN
|
||||
):
|
||||
if 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
|
||||
@@ -208,19 +170,19 @@ async def async_initialize_integration(
|
||||
|
||||
await async_try_startup()
|
||||
|
||||
# Remove old (v0-v1) sensor if it exists, can be removed in v3
|
||||
er = async_get_entity_registry(hass)
|
||||
if old_sensor := er.async_get_entity_id("sensor", DOMAIN, HACS_SYSTEM_ID):
|
||||
er.async_remove(old_sensor)
|
||||
|
||||
# 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)
|
||||
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
|
||||
|
||||
@@ -229,10 +191,14 @@ async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) ->
|
||||
"""Handle removal of an entry."""
|
||||
hacs: HacsBase = hass.data[DOMAIN]
|
||||
|
||||
if hacs.queue.has_pending_tasks:
|
||||
hacs.log.warning("Pending tasks, can not unload, try again later.")
|
||||
return False
|
||||
|
||||
# Clear out pending queue
|
||||
hacs.queue.clear()
|
||||
|
||||
for task in hacs.recuring_tasks:
|
||||
for task in hacs.recurring_tasks:
|
||||
# Cancel all pending tasks
|
||||
task()
|
||||
|
||||
@@ -242,15 +208,11 @@ async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) ->
|
||||
try:
|
||||
if hass.data.get("frontend_panels", {}).get("hacs"):
|
||||
hacs.log.info("Removing sidepanel")
|
||||
hass.components.frontend.async_remove_panel("hacs")
|
||||
async_remove_panel(hass, "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)
|
||||
unload_ok = await hass.config_entries.async_unload_platforms(config_entry, PLATFORMS)
|
||||
|
||||
hacs.set_stage(None)
|
||||
hacs.disable_hacs(HacsDisabledReason.REMOVED)
|
||||
@@ -262,5 +224,6 @@ async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) ->
|
||||
|
||||
async def async_reload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> None:
|
||||
"""Reload the HACS config entry."""
|
||||
await async_unload_entry(hass, config_entry)
|
||||
if not await async_unload_entry(hass, config_entry):
|
||||
return
|
||||
await async_setup_entry(hass, config_entry)
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
"""Base HACS class."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Awaitable, Callable
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from datetime import timedelta
|
||||
import gzip
|
||||
import json
|
||||
import logging
|
||||
import math
|
||||
import os
|
||||
import pathlib
|
||||
import shutil
|
||||
from typing import TYPE_CHECKING, Any, Awaitable, Callable
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from aiogithubapi import (
|
||||
AIOGitHubAPIException,
|
||||
@@ -25,16 +25,22 @@ from aiogithubapi import (
|
||||
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.components.persistent_notification import (
|
||||
async_create as async_create_persistent_notification,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
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.event import async_track_time_interval
|
||||
from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
|
||||
from homeassistant.loader import Integration
|
||||
from homeassistant.util import dt
|
||||
|
||||
from .const import TV
|
||||
from .const import DOMAIN, TV, URL_BASE
|
||||
from .coordinator import HacsUpdateCoordinator
|
||||
from .data_client import HacsDataClient
|
||||
from .enums import (
|
||||
ConfigurationType,
|
||||
HacsCategory,
|
||||
HacsDisabledReason,
|
||||
HacsDispatchEvent,
|
||||
@@ -47,15 +53,19 @@ from .exceptions import (
|
||||
HacsException,
|
||||
HacsExecutionStillInProgress,
|
||||
HacsExpectedException,
|
||||
HacsNotModifiedException,
|
||||
HacsRepositoryArchivedException,
|
||||
HacsRepositoryExistException,
|
||||
HomeAssistantCoreRepositoryException,
|
||||
)
|
||||
from .repositories import RERPOSITORY_CLASSES
|
||||
from .utils.decode import decode_content
|
||||
from .utils.logger import get_hacs_logger
|
||||
from .repositories import REPOSITORY_CLASSES
|
||||
from .repositories.base import HACS_MANIFEST_KEYS_TO_EXPORT, REPOSITORY_KEYS_TO_EXPORT
|
||||
from .utils.file_system import async_exists
|
||||
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
|
||||
from .utils.workarounds import async_register_static_path
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .repositories.base import HacsRepository
|
||||
@@ -105,15 +115,11 @@ class HacsConfiguration:
|
||||
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
|
||||
@@ -134,6 +140,8 @@ class HacsConfiguration:
|
||||
raise HacsException("Configuration is not valid.")
|
||||
|
||||
for key in data:
|
||||
if key in {"experimental", "netdaemon", "release_limit", "debug"}:
|
||||
continue
|
||||
self.__setattr__(key, data[key])
|
||||
|
||||
|
||||
@@ -152,9 +160,9 @@ class HacsCommon:
|
||||
|
||||
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)
|
||||
archived_repositories: set[str] = field(default_factory=set)
|
||||
ignored_repositories: set[str] = field(default_factory=set)
|
||||
skip: set[str] = field(default_factory=set)
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -163,8 +171,9 @@ class HacsStatus:
|
||||
|
||||
startup: bool = True
|
||||
new: bool = False
|
||||
reloading_data: bool = False
|
||||
upgrading_all: bool = False
|
||||
active_frontend_endpoint_plugin: bool = False
|
||||
active_frontend_endpoint_theme: bool = False
|
||||
inital_fetch_done: bool = False
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -175,6 +184,7 @@ class HacsSystem:
|
||||
running: bool = False
|
||||
stage = HacsStage.SETUP
|
||||
action: bool = False
|
||||
generator: bool = False
|
||||
|
||||
@property
|
||||
def disabled(self) -> bool:
|
||||
@@ -187,26 +197,33 @@ 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, str] = field(default_factory=dict)
|
||||
_repositories_by_id: dict[str, str] = field(default_factory=dict)
|
||||
_removed_repositories: list[RemovedRepository] = field(default_factory=list)
|
||||
_repositories: set[HacsRepository] = field(default_factory=set)
|
||||
_repositories_by_full_name: dict[str, HacsRepository] = field(default_factory=dict)
|
||||
_repositories_by_id: dict[str, HacsRepository] = field(default_factory=dict)
|
||||
_removed_repositories_by_full_name: dict[str, RemovedRepository] = field(default_factory=dict)
|
||||
|
||||
@property
|
||||
def list_all(self) -> list[HacsRepository]:
|
||||
"""Return a list of repositories."""
|
||||
return self._repositories
|
||||
return list(self._repositories)
|
||||
|
||||
@property
|
||||
def list_removed(self) -> list[RemovedRepository]:
|
||||
"""Return a list of removed repositories."""
|
||||
return self._removed_repositories
|
||||
return list(self._removed_repositories_by_full_name.values())
|
||||
|
||||
@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 category_downloaded(self, category: HacsCategory) -> bool:
|
||||
"""Check if a given category has been downloaded."""
|
||||
for repository in self.list_downloaded:
|
||||
if repository.data.category == category:
|
||||
return True
|
||||
return False
|
||||
|
||||
def register(self, repository: HacsRepository, default: bool = False) -> None:
|
||||
"""Register a repository."""
|
||||
repo_id = str(repository.data.id)
|
||||
@@ -214,11 +231,18 @@ class HacsRepositories:
|
||||
if repo_id == "0":
|
||||
return
|
||||
|
||||
if self.is_registered(repository_id=repo_id):
|
||||
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.add(repository)
|
||||
|
||||
self._repositories_by_id[repo_id] = repository
|
||||
self._repositories_by_full_name[repository.data.full_name_lower] = repository
|
||||
@@ -257,7 +281,7 @@ class HacsRepositories:
|
||||
|
||||
self._default_repositories.add(repo_id)
|
||||
|
||||
def set_repository_id(self, repository, repo_id):
|
||||
def set_repository_id(self, repository: HacsRepository, repo_id: str):
|
||||
"""Update a repository id."""
|
||||
existing_repo_id = str(repository.data.id)
|
||||
if existing_repo_id == repo_id:
|
||||
@@ -316,48 +340,46 @@ class HacsRepositories:
|
||||
|
||||
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
|
||||
)
|
||||
return repository_full_name in self._removed_repositories_by_full_name
|
||||
|
||||
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]
|
||||
if removed := self._removed_repositories_by_full_name.get(repository_full_name):
|
||||
return removed
|
||||
|
||||
removed = RemovedRepository(repository=repository_full_name)
|
||||
self._removed_repositories.append(removed)
|
||||
self._removed_repositories_by_full_name[repository_full_name] = removed
|
||||
return removed
|
||||
|
||||
|
||||
class HacsBase:
|
||||
"""Base HACS class."""
|
||||
|
||||
common = HacsCommon()
|
||||
configuration = HacsConfiguration()
|
||||
core = HacsCore()
|
||||
data: HacsData | None = None
|
||||
data_client: HacsDataClient | 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 = get_hacs_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
|
||||
version: AwesomeVersion | None = None
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize."""
|
||||
self.common = HacsCommon()
|
||||
self.configuration = HacsConfiguration()
|
||||
self.coordinators: dict[HacsCategory, HacsUpdateCoordinator] = {}
|
||||
self.core = HacsCore()
|
||||
self.log = LOGGER
|
||||
self.recurring_tasks: list[Callable[[], None]] = []
|
||||
self.repositories = HacsRepositories()
|
||||
self.status = HacsStatus()
|
||||
self.system = HacsSystem()
|
||||
|
||||
@property
|
||||
def integration_dir(self) -> pathlib.Path:
|
||||
@@ -383,12 +405,7 @@ class HacsBase:
|
||||
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"
|
||||
if reason == HacsDisabledReason.INVALID_TOKEN:
|
||||
self.hass.add_job(self.configuration.config_entry.async_start_reauth, self.hass)
|
||||
|
||||
def enable_hacs(self) -> None:
|
||||
@@ -402,12 +419,14 @@ class HacsBase:
|
||||
if category not in self.common.categories:
|
||||
self.log.info("Enable category: %s", category)
|
||||
self.common.categories.add(category)
|
||||
self.coordinators[category] = HacsUpdateCoordinator()
|
||||
|
||||
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)
|
||||
self.coordinators.pop(category)
|
||||
|
||||
async def async_save_file(self, file_path: str, content: Any) -> bool:
|
||||
"""Save a file."""
|
||||
@@ -439,11 +458,14 @@ class HacsBase:
|
||||
|
||||
try:
|
||||
await self.hass.async_add_executor_job(_write_file)
|
||||
except BaseException as error: # lgtm [py/catch-base-exception] pylint: disable=broad-except
|
||||
except (
|
||||
# lgtm [py/catch-base-exception] pylint: disable=broad-except
|
||||
BaseException
|
||||
) as error:
|
||||
self.log.error("Could not write data to %s - %s", file_path, error)
|
||||
return False
|
||||
|
||||
return os.path.exists(file_path)
|
||||
return await async_exists(self.hass, file_path)
|
||||
|
||||
async def async_can_update(self) -> int:
|
||||
"""Helper to calculate the number of repositories we can fetch data for."""
|
||||
@@ -458,23 +480,14 @@ class HacsBase:
|
||||
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
|
||||
except (
|
||||
# lgtm [py/catch-base-exception] pylint: disable=broad-except
|
||||
BaseException
|
||||
) as exception:
|
||||
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]],
|
||||
@@ -497,7 +510,10 @@ class HacsBase:
|
||||
raise exception
|
||||
except GitHubException as exception:
|
||||
_exception = exception
|
||||
except BaseException as exception: # lgtm [py/catch-base-exception] pylint: disable=broad-except
|
||||
except (
|
||||
# lgtm [py/catch-base-exception] pylint: disable=broad-except
|
||||
BaseException
|
||||
) as exception:
|
||||
self.log.exception(exception)
|
||||
_exception = exception
|
||||
|
||||
@@ -528,49 +544,56 @@ class HacsBase:
|
||||
):
|
||||
raise AddonRepositoryException()
|
||||
|
||||
if category not in RERPOSITORY_CLASSES:
|
||||
raise HacsException(f"{category} is not a valid repository category.")
|
||||
if category not in REPOSITORY_CLASSES:
|
||||
self.log.warning(
|
||||
"%s is not a valid repository category, %s will not be registered.",
|
||||
category,
|
||||
repository_full_name,
|
||||
)
|
||||
return
|
||||
|
||||
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)
|
||||
repository: HacsRepository = REPOSITORY_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)
|
||||
self.common.skip.add(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."
|
||||
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):
|
||||
except (HacsRepositoryExistException, HacsRepositoryArchivedException) as exception:
|
||||
if self.system.generator:
|
||||
repository.logger.error(
|
||||
"%s Registration Failed - %s", repository.string, exception
|
||||
)
|
||||
return
|
||||
except AIOGitHubAPIException as exception:
|
||||
self.common.skip.append(repository.data.full_name)
|
||||
self.common.skip.add(repository.data.full_name)
|
||||
raise HacsException(
|
||||
f"Validation for {repository_full_name} failed with {exception}."
|
||||
f"Validation for {
|
||||
repository_full_name} failed with {exception}."
|
||||
) from exception
|
||||
|
||||
if self.status.new:
|
||||
repository.data.new = False
|
||||
|
||||
if repository_id is not None:
|
||||
repository.data.id = repository_id
|
||||
|
||||
if str(repository.data.id) != "0" and (
|
||||
exists := self.repositories.get_by_id(repository.data.id)
|
||||
):
|
||||
self.repositories.unregister(exists)
|
||||
|
||||
else:
|
||||
if self.hass is not None and ((check and repository.data.new) or self.status.new):
|
||||
if self.hass is not None and check and repository.data.new:
|
||||
self.async_dispatch(
|
||||
HacsDispatchEvent.REPOSITORY,
|
||||
{
|
||||
@@ -585,104 +608,90 @@ class HacsBase:
|
||||
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)
|
||||
await self.async_load_hacs_from_github()
|
||||
|
||||
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!**"
|
||||
async_create_persistent_notification(
|
||||
self.hass, 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.recurring_tasks.append(
|
||||
async_track_time_interval(
|
||||
self.hass,
|
||||
self.async_load_hacs_from_github,
|
||||
timedelta(hours=48),
|
||||
)
|
||||
)
|
||||
|
||||
self.hass.bus.async_listen_once(
|
||||
self.recurring_tasks.append(
|
||||
async_track_time_interval(
|
||||
self.hass, self.async_update_downloaded_custom_repositories, timedelta(hours=48)
|
||||
)
|
||||
)
|
||||
|
||||
self.recurring_tasks.append(
|
||||
async_track_time_interval(
|
||||
self.hass, self.async_get_all_category_repositories, timedelta(hours=6)
|
||||
)
|
||||
)
|
||||
|
||||
self.recurring_tasks.append(
|
||||
async_track_time_interval(self.hass, self.async_check_rate_limit, timedelta(minutes=5))
|
||||
)
|
||||
self.recurring_tasks.append(
|
||||
async_track_time_interval(self.hass, self.async_process_queue, timedelta(minutes=10))
|
||||
)
|
||||
|
||||
self.recurring_tasks.append(
|
||||
async_track_time_interval(
|
||||
self.hass, self.async_handle_critical_repositories, timedelta(hours=6)
|
||||
)
|
||||
)
|
||||
|
||||
unsub = self.hass.bus.async_listen_once(
|
||||
EVENT_HOMEASSISTANT_FINAL_WRITE, self.data.async_force_write
|
||||
)
|
||||
if config_entry := self.configuration.config_entry:
|
||||
config_entry.async_on_unload(unsub)
|
||||
|
||||
self.log.debug("There are %s scheduled recurring tasks", len(self.recurring_tasks))
|
||||
|
||||
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()
|
||||
await self.async_process_queue()
|
||||
|
||||
self.async_dispatch(HacsDispatchEvent.STATUS, {})
|
||||
|
||||
async def async_download_file(self, url: str, *, headers: dict | None = None) -> bytes | None:
|
||||
async def async_download_file(
|
||||
self,
|
||||
url: str,
|
||||
*,
|
||||
headers: dict | None = None,
|
||||
keep_url: bool = False,
|
||||
nolog: bool = False,
|
||||
**_,
|
||||
) -> bytes | None:
|
||||
"""Download files, and return the content."""
|
||||
if url is None:
|
||||
return None
|
||||
|
||||
if "tags/" in url:
|
||||
if not keep_url and "tags/" in url:
|
||||
url = url.replace("tags/", "")
|
||||
|
||||
self.log.debug("Downloading %s", url)
|
||||
self.log.debug("Trying to download %s", url)
|
||||
timeouts = 0
|
||||
|
||||
while timeouts < 5:
|
||||
@@ -698,9 +707,10 @@ class HacsBase:
|
||||
return await request.read()
|
||||
|
||||
raise HacsException(
|
||||
f"Got status code {request.status} when trying to download {url}"
|
||||
f"Got status code {
|
||||
request.status} when trying to download {url}"
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
except 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. "
|
||||
@@ -715,24 +725,38 @@ class HacsBase:
|
||||
await asyncio.sleep(1)
|
||||
continue
|
||||
|
||||
except BaseException as exception: # lgtm [py/catch-base-exception] pylint: disable=broad-except
|
||||
except (
|
||||
# lgtm [py/catch-base-exception] pylint: disable=broad-except
|
||||
BaseException
|
||||
) as exception:
|
||||
if not nolog:
|
||||
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]
|
||||
platforms = [Platform.UPDATE]
|
||||
|
||||
# Workaround for core versions without https://github.com/home-assistant/core/pull/117084
|
||||
if self.core.ha_version < AwesomeVersion("2024.6.0"):
|
||||
unload_platforms_lock = asyncio.Lock()
|
||||
async with unload_platforms_lock:
|
||||
on_unload = self.configuration.config_entry._on_unload
|
||||
self.configuration.config_entry._on_unload = []
|
||||
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)
|
||||
self.configuration.config_entry._on_unload = on_unload
|
||||
else:
|
||||
await self.hass.config_entries.async_unload_platforms(
|
||||
entry=self.configuration.config_entry,
|
||||
platforms=platforms,
|
||||
)
|
||||
await self.hass.config_entries.async_forward_entry_setups(
|
||||
self.configuration.config_entry, platforms
|
||||
)
|
||||
|
||||
@callback
|
||||
def async_dispatch(self, signal: HacsDispatchEvent, data: dict | None = None) -> None:
|
||||
@@ -742,19 +766,63 @@ class HacsBase:
|
||||
def set_active_categories(self) -> None:
|
||||
"""Set the active categories."""
|
||||
self.common.categories = set()
|
||||
for category in (HacsCategory.INTEGRATION, HacsCategory.PLUGIN):
|
||||
for category in (HacsCategory.INTEGRATION, HacsCategory.PLUGIN, HacsCategory.TEMPLATE):
|
||||
self.enable_hacs_category(HacsCategory(category))
|
||||
|
||||
if HacsCategory.PYTHON_SCRIPT in self.hass.config.components:
|
||||
if (
|
||||
HacsCategory.PYTHON_SCRIPT in self.hass.config.components
|
||||
or self.repositories.category_downloaded(HacsCategory.PYTHON_SCRIPT)
|
||||
):
|
||||
self.enable_hacs_category(HacsCategory.PYTHON_SCRIPT)
|
||||
|
||||
if self.hass.services.has_service("frontend", "reload_themes"):
|
||||
if self.hass.services.has_service(
|
||||
"frontend", "reload_themes"
|
||||
) or self.repositories.category_downloaded(HacsCategory.THEME):
|
||||
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_load_hacs_from_github(self, _=None) -> None:
|
||||
"""Load HACS from GitHub."""
|
||||
if self.status.inital_fetch_done:
|
||||
return
|
||||
|
||||
try:
|
||||
repository = self.repositories.get_by_full_name(HacsGitHubRepo.INTEGRATION)
|
||||
should_recreate_entities = False
|
||||
if repository is None:
|
||||
should_recreate_entities = True
|
||||
await self.async_register_repository(
|
||||
repository_full_name=HacsGitHubRepo.INTEGRATION,
|
||||
category=HacsCategory.INTEGRATION,
|
||||
default=True,
|
||||
)
|
||||
repository = self.repositories.get_by_full_name(HacsGitHubRepo.INTEGRATION)
|
||||
elif not self.status.startup:
|
||||
self.log.error("Scheduling update of hacs/integration")
|
||||
self.queue.add(repository.common_update())
|
||||
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
|
||||
|
||||
if should_recreate_entities:
|
||||
await self.async_recreate_entities()
|
||||
|
||||
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)
|
||||
|
||||
async def async_get_all_category_repositories(self, _=None) -> None:
|
||||
"""Get all category repositories."""
|
||||
@@ -763,55 +831,62 @@ class HacsBase:
|
||||
self.log.info("Loading known repositories")
|
||||
await asyncio.gather(
|
||||
*[
|
||||
self.async_get_category_repositories(HacsCategory(category))
|
||||
self.async_get_category_repositories_experimental(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
|
||||
async def async_get_category_repositories_experimental(self, category: str) -> None:
|
||||
"""Update all category repositories."""
|
||||
self.log.debug("Fetching updated content for %s", category)
|
||||
try:
|
||||
repositories = await self.async_github_get_hacs_default_file(category)
|
||||
except HacsException:
|
||||
category_data = await self.data_client.get_data(category, validate=True)
|
||||
except HacsNotModifiedException:
|
||||
self.log.debug("No updates for %s", category)
|
||||
return
|
||||
except HacsException as exception:
|
||||
self.log.error("Could not update %s - %s", category, exception)
|
||||
return
|
||||
|
||||
for repo in repositories:
|
||||
if self.common.renamed_repositories.get(repo):
|
||||
repo = self.common.renamed_repositories[repo]
|
||||
if self.repositories.is_removed(repo):
|
||||
await self.data.register_unknown_repositories(category_data, category)
|
||||
|
||||
for repo_id, repo_data in category_data.items():
|
||||
repo_name = repo_data["full_name"]
|
||||
if self.common.renamed_repositories.get(repo_name):
|
||||
repo_name = self.common.renamed_repositories[repo_name]
|
||||
if self.repositories.is_removed(repo_name):
|
||||
continue
|
||||
if repo in self.common.archived_repositories:
|
||||
if repo_name in self.common.archived_repositories:
|
||||
continue
|
||||
repository = self.repositories.get_by_full_name(repo)
|
||||
if repository is not None:
|
||||
if repository := self.repositories.get_by_full_name(repo_name):
|
||||
self.repositories.set_repository_id(repository, repo_id)
|
||||
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,
|
||||
)
|
||||
if repository.data.last_fetched is None or (
|
||||
repository.data.last_fetched.timestamp() < repo_data["last_fetched"]
|
||||
):
|
||||
repository.data.update_data({**dict(REPOSITORY_KEYS_TO_EXPORT), **repo_data})
|
||||
if (manifest := repo_data.get("manifest")) is not None:
|
||||
repository.repository_manifest.update_data(
|
||||
{**dict(HACS_MANIFEST_KEYS_TO_EXPORT), **manifest}
|
||||
)
|
||||
|
||||
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")
|
||||
if category == "integration":
|
||||
self.status.inital_fetch_done = True
|
||||
|
||||
if self.stage == HacsStage.STARTUP:
|
||||
for repository in self.repositories.list_all:
|
||||
if repository.data.category in self.common.categories:
|
||||
self.queue.add(repository.common_update())
|
||||
if (
|
||||
repository.data.category == category
|
||||
and not repository.data.installed
|
||||
and not self.repositories.is_default(repository.data.id)
|
||||
):
|
||||
repository.logger.debug(
|
||||
"%s Unregister stale custom repository", repository.string
|
||||
)
|
||||
self.repositories.unregister(repository)
|
||||
|
||||
self.async_dispatch(HacsDispatchEvent.REPOSITORY, {"action": "reload"})
|
||||
self.log.debug("Recurring background task for all repositories done")
|
||||
self.async_dispatch(HacsDispatchEvent.REPOSITORY, {})
|
||||
self.coordinators[category].async_update_listeners()
|
||||
|
||||
async def async_check_rate_limit(self, _=None) -> None:
|
||||
"""Check rate limit."""
|
||||
@@ -823,9 +898,9 @@ class HacsBase:
|
||||
self.log.debug("Ratelimit indicate we can update %s", can_update)
|
||||
if can_update > 0:
|
||||
self.enable_hacs()
|
||||
await self.async_prosess_queue()
|
||||
await self.async_process_queue()
|
||||
|
||||
async def async_prosess_queue(self, _=None) -> None:
|
||||
async def async_process_queue(self, _=None) -> None:
|
||||
"""Process the queue."""
|
||||
if self.system.disabled:
|
||||
self.log.debug("HACS is disabled")
|
||||
@@ -843,7 +918,7 @@ class HacsBase:
|
||||
return
|
||||
can_update = await self.async_can_update()
|
||||
self.log.debug(
|
||||
"Can update %s repositories, " "items in queue %s",
|
||||
"Can update %s repositories, items in queue %s",
|
||||
can_update,
|
||||
self.queue.pending_tasks,
|
||||
)
|
||||
@@ -865,9 +940,7 @@ class HacsBase:
|
||||
self.log.info("Loading removed repositories")
|
||||
|
||||
try:
|
||||
removed_repositories = await self.async_github_get_hacs_default_file(
|
||||
HacsCategory.REMOVED
|
||||
)
|
||||
removed_repositories = await self.data_client.get_data("removed", validate=True)
|
||||
except HacsException:
|
||||
return
|
||||
|
||||
@@ -880,7 +953,22 @@ class HacsBase:
|
||||
continue
|
||||
if repository.data.full_name in self.common.ignored_repositories:
|
||||
continue
|
||||
if repository.data.installed and removed.removal_type != "critical":
|
||||
if repository.data.installed:
|
||||
if removed.removal_type != "critical":
|
||||
async_create_issue(
|
||||
hass=self.hass,
|
||||
domain=DOMAIN,
|
||||
issue_id=f"removed_{repository.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. "
|
||||
@@ -895,17 +983,45 @@ class HacsBase:
|
||||
if need_to_save:
|
||||
await self.data.async_write()
|
||||
|
||||
async def async_update_downloaded_repositories(self, _=None) -> None:
|
||||
async def async_update_downloaded_custom_repositories(self, _=None) -> None:
|
||||
"""Execute the task."""
|
||||
if self.system.disabled:
|
||||
return
|
||||
self.log.info("Starting recurring background task for downloaded repositories")
|
||||
self.log.info("Starting recurring background task for downloaded custom repositories")
|
||||
|
||||
repositories_to_update = 0
|
||||
repositories_updated = asyncio.Event()
|
||||
|
||||
async def update_repository(repository: HacsRepository) -> None:
|
||||
"""Update a repository"""
|
||||
nonlocal repositories_to_update
|
||||
await repository.update_repository(ignore_issues=True)
|
||||
repositories_to_update -= 1
|
||||
if not repositories_to_update:
|
||||
repositories_updated.set()
|
||||
|
||||
for repository in self.repositories.list_downloaded:
|
||||
if repository.data.category in self.common.categories:
|
||||
self.queue.add(repository.update_repository())
|
||||
if (
|
||||
repository.data.category in self.common.categories
|
||||
and not self.repositories.is_default(repository.data.id)
|
||||
):
|
||||
repositories_to_update += 1
|
||||
self.queue.add(update_repository(repository))
|
||||
|
||||
self.log.debug("Recurring background task for downloaded repositories done")
|
||||
async def update_coordinators() -> None:
|
||||
"""Update all coordinators."""
|
||||
await repositories_updated.wait()
|
||||
for coordinator in self.coordinators.values():
|
||||
coordinator.async_update_listeners()
|
||||
|
||||
if config_entry := self.configuration.config_entry:
|
||||
config_entry.async_create_background_task(
|
||||
self.hass, update_coordinators(), "update_coordinators"
|
||||
)
|
||||
else:
|
||||
self.hass.async_create_background_task(update_coordinators(), "update_coordinators")
|
||||
|
||||
self.log.debug("Recurring background task for downloaded custom repositories done")
|
||||
|
||||
async def async_handle_critical_repositories(self, _=None) -> None:
|
||||
"""Handle critical repositories."""
|
||||
@@ -915,8 +1031,8 @@ class HacsBase:
|
||||
was_installed = False
|
||||
|
||||
try:
|
||||
critical = await self.async_github_get_hacs_default_file("critical")
|
||||
except GitHubNotModifiedException:
|
||||
critical = await self.data_client.get_data("critical", validate=True)
|
||||
except (GitHubNotModifiedException, HacsNotModifiedException):
|
||||
return
|
||||
except HacsException:
|
||||
pass
|
||||
@@ -968,3 +1084,27 @@ class HacsBase:
|
||||
if was_installed:
|
||||
self.log.critical("Restarting Home Assistant")
|
||||
self.hass.async_create_task(self.hass.async_stop(100))
|
||||
|
||||
async def async_setup_frontend_endpoint_plugin(self) -> None:
|
||||
"""Setup the http endpoints for plugins if its not already handled."""
|
||||
if self.status.active_frontend_endpoint_plugin or not await async_exists(
|
||||
self.hass, self.hass.config.path("www/community")
|
||||
):
|
||||
return
|
||||
|
||||
self.log.info("Setting up plugin endpoint")
|
||||
use_cache = self.core.lovelace_mode == "storage"
|
||||
self.log.info(
|
||||
"<HacsFrontend> %s mode, cache for /hacsfiles/: %s",
|
||||
self.core.lovelace_mode,
|
||||
use_cache,
|
||||
)
|
||||
|
||||
await async_register_static_path(
|
||||
self.hass,
|
||||
URL_BASE,
|
||||
self.hass.config.path("www/community"),
|
||||
cache_headers=use_cache,
|
||||
)
|
||||
|
||||
self.status.active_frontend_endpoint_plugin = True
|
||||
|
||||
@@ -1,37 +1,58 @@
|
||||
"""Adds config flow for HACS."""
|
||||
from aiogithubapi import GitHubDeviceAPI, GitHubException
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from contextlib import suppress
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from aiogithubapi import (
|
||||
GitHubDeviceAPI,
|
||||
GitHubException,
|
||||
GitHubLoginDeviceModel,
|
||||
GitHubLoginOauthModel,
|
||||
)
|
||||
from aiogithubapi.common.const import OAUTH_USER_LOGIN
|
||||
from awesomeversion import AwesomeVersion
|
||||
from homeassistant import config_entries
|
||||
from homeassistant.config_entries import ConfigFlow, OptionsFlow
|
||||
from homeassistant.const import __version__ as HAVERSION
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.data_entry_flow import UnknownFlow
|
||||
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 get_hacs_logger
|
||||
from .const import CLIENT_ID, DOMAIN, LOCALE, MINIMUM_HA_VERSION
|
||||
from .utils.configuration_schema import (
|
||||
APPDAEMON,
|
||||
COUNTRY,
|
||||
SIDEPANEL_ICON,
|
||||
SIDEPANEL_TITLE,
|
||||
)
|
||||
from .utils.logger import LOGGER
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
|
||||
class HacsFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
class HacsFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
"""Config flow for HACS."""
|
||||
|
||||
VERSION = 1
|
||||
CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL
|
||||
|
||||
def __init__(self):
|
||||
hass: HomeAssistant
|
||||
activation_task: asyncio.Task | None = None
|
||||
device: GitHubDeviceAPI | None = None
|
||||
|
||||
_registration: GitHubLoginDeviceModel | None = None
|
||||
_activation: GitHubLoginOauthModel | None = None
|
||||
_reauth: bool = False
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize."""
|
||||
self._errors = {}
|
||||
self.device = None
|
||||
self.activation = None
|
||||
self.log = get_hacs_logger()
|
||||
self._progress_task = None
|
||||
self._login_device = None
|
||||
self._reauth = False
|
||||
self._user_input = {}
|
||||
|
||||
async def async_step_user(self, user_input):
|
||||
"""Handle a flow initialized by the user."""
|
||||
@@ -42,55 +63,64 @@ class HacsFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
return self.async_abort(reason="single_instance_allowed")
|
||||
|
||||
if user_input:
|
||||
if [x for x in user_input if not user_input[x]]:
|
||||
if [x for x in user_input if x.startswith("acc_") and not user_input[x]]:
|
||||
self._errors["base"] = "acc"
|
||||
return await self._show_config_form(user_input)
|
||||
|
||||
self._user_input = user_input
|
||||
|
||||
return await self.async_step_device(user_input)
|
||||
|
||||
## Initial form
|
||||
# Initial form
|
||||
return await self._show_config_form(user_input)
|
||||
|
||||
async def async_step_device(self, _user_input):
|
||||
"""Handle device steps"""
|
||||
"""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
|
||||
async def _wait_for_activation() -> None:
|
||||
try:
|
||||
response = await self.device.activation(device_code=self._registration.device_code)
|
||||
self._activation = response.data
|
||||
finally:
|
||||
|
||||
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)
|
||||
)
|
||||
async def _progress():
|
||||
with suppress(UnknownFlow):
|
||||
await 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:
|
||||
integration = await async_get_integration(self.hass, DOMAIN)
|
||||
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,
|
||||
},
|
||||
)
|
||||
self._registration = response.data
|
||||
except GitHubException as exception:
|
||||
self.log.error(exception)
|
||||
return self.async_abort(reason="github")
|
||||
LOGGER.exception(exception)
|
||||
return self.async_abort(reason="could_not_register")
|
||||
|
||||
if self.activation_task is None:
|
||||
self.activation_task = self.hass.async_create_task(_wait_for_activation())
|
||||
|
||||
if self.activation_task.done():
|
||||
if (exception := self.activation_task.exception()) is not None:
|
||||
LOGGER.exception(exception)
|
||||
return self.async_show_progress_done(next_step_id="could_not_register")
|
||||
return self.async_show_progress_done(next_step_id="device_done")
|
||||
|
||||
show_progress_kwargs = {
|
||||
"step_id": "device",
|
||||
"progress_action": "wait_for_device",
|
||||
"description_placeholders": {
|
||||
"url": OAUTH_USER_LOGIN,
|
||||
"code": self._registration.user_code,
|
||||
},
|
||||
"progress_task": self.activation_task,
|
||||
}
|
||||
return self.async_show_progress(**show_progress_kwargs)
|
||||
|
||||
async def _show_config_form(self, user_input):
|
||||
"""Show the configuration form to edit location data."""
|
||||
|
||||
@@ -117,19 +147,31 @@ class HacsFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
errors=self._errors,
|
||||
)
|
||||
|
||||
async def async_step_device_done(self, _user_input):
|
||||
async def async_step_device_done(self, user_input: dict[str, bool] | None = None):
|
||||
"""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}
|
||||
existing_entry, data={**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})
|
||||
return self.async_create_entry(
|
||||
title="",
|
||||
data={
|
||||
"token": self._activation.access_token,
|
||||
},
|
||||
options={
|
||||
"experimental": True,
|
||||
},
|
||||
)
|
||||
|
||||
async def async_step_reauth(self, user_input=None):
|
||||
async def async_step_could_not_register(self, _user_input=None):
|
||||
"""Handle issues that need transition await from progress step."""
|
||||
return self.async_abort(reason="could_not_register")
|
||||
|
||||
async def async_step_reauth(self, _user_input=None):
|
||||
"""Perform reauth upon an API authentication error."""
|
||||
return await self.async_step_reauth_confirm()
|
||||
|
||||
@@ -149,11 +191,12 @@ class HacsFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
return HacsOptionsFlowHandler(config_entry)
|
||||
|
||||
|
||||
class HacsOptionsFlowHandler(config_entries.OptionsFlow):
|
||||
class HacsOptionsFlowHandler(OptionsFlow):
|
||||
"""HACS config flow options handler."""
|
||||
|
||||
def __init__(self, config_entry):
|
||||
"""Initialize HACS options flow."""
|
||||
if AwesomeVersion(HAVERSION) < "2024.11.99":
|
||||
self.config_entry = config_entry
|
||||
|
||||
async def async_step_init(self, _user_input=None):
|
||||
@@ -164,19 +207,19 @@ class HacsOptionsFlowHandler(config_entries.OptionsFlow):
|
||||
"""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)
|
||||
return self.async_create_entry(title="", data={**user_input, "experimental": True})
|
||||
|
||||
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"]
|
||||
if hacs.queue.has_pending_tasks:
|
||||
return self.async_abort(reason="pending_tasks")
|
||||
|
||||
schema = {
|
||||
vol.Optional(SIDEPANEL_TITLE, default=hacs.configuration.sidepanel_title): str,
|
||||
vol.Optional(SIDEPANEL_ICON, default=hacs.configuration.sidepanel_icon): str,
|
||||
vol.Optional(COUNTRY, default=hacs.configuration.country): vol.In(LOCALE),
|
||||
vol.Optional(APPDAEMON, default=hacs.configuration.appdaemon): bool,
|
||||
}
|
||||
|
||||
return self.async_show_form(step_id="user", data_schema=vol.Schema(schema))
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Constants for HACS"""
|
||||
|
||||
from typing import TypeVar
|
||||
|
||||
from aiogithubapi.common.const import ACCEPT_HEADERS
|
||||
@@ -6,7 +7,9 @@ from aiogithubapi.common.const import ACCEPT_HEADERS
|
||||
NAME_SHORT = "HACS"
|
||||
DOMAIN = "hacs"
|
||||
CLIENT_ID = "395a8e669c5de9f7c6e8"
|
||||
MINIMUM_HA_VERSION = "2022.4.0"
|
||||
MINIMUM_HA_VERSION = "2024.4.1"
|
||||
|
||||
URL_BASE = "/hacsfiles"
|
||||
|
||||
TV = TypeVar("TV")
|
||||
|
||||
@@ -15,6 +18,8 @@ PACKAGE_NAME = "custom_components.hacs"
|
||||
DEFAULT_CONCURRENT_TASKS = 15
|
||||
DEFAULT_CONCURRENT_BACKOFF_TIME = 1
|
||||
|
||||
HACS_REPOSITORY_ID = "172733314"
|
||||
|
||||
HACS_ACTION_GITHUB_API_HEADERS = {
|
||||
"User-Agent": "HACS/action",
|
||||
"Accept": ACCEPT_HEADERS["preview"],
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Diagnostics support for HACS."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
@@ -10,7 +11,6 @@ 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(
|
||||
@@ -48,8 +48,6 @@ async def async_get_config_entry_diagnostics(
|
||||
"country",
|
||||
"debug",
|
||||
"dev",
|
||||
"experimental",
|
||||
"netdaemon",
|
||||
"python_script",
|
||||
"release_limit",
|
||||
"theme",
|
||||
@@ -79,4 +77,4 @@ async def async_get_config_entry_diagnostics(
|
||||
except GitHubException as exception:
|
||||
data["rate_limit"] = str(exception)
|
||||
|
||||
return async_redact_data(data, (TOKEN,))
|
||||
return async_redact_data(data, ("token",))
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""HACS Base entities."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Any
|
||||
@@ -7,8 +8,10 @@ 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 homeassistant.helpers.update_coordinator import BaseCoordinatorEntity
|
||||
|
||||
from .const import DOMAIN, HACS_SYSTEM_ID, NAME_SHORT
|
||||
from .coordinator import HacsUpdateCoordinator
|
||||
from .enums import HacsDispatchEvent, HacsGitHubRepo
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -39,6 +42,10 @@ class HacsBaseEntity(Entity):
|
||||
"""Initialize."""
|
||||
self.hacs = hacs
|
||||
|
||||
|
||||
class HacsDispatcherEntity(HacsBaseEntity):
|
||||
"""Base HACS entity listening to dispatcher signals."""
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Register for status events."""
|
||||
self.async_on_remove(
|
||||
@@ -64,7 +71,7 @@ class HacsBaseEntity(Entity):
|
||||
self.async_write_ha_state()
|
||||
|
||||
|
||||
class HacsSystemEntity(HacsBaseEntity):
|
||||
class HacsSystemEntity(HacsDispatcherEntity):
|
||||
"""Base system entity."""
|
||||
|
||||
_attr_icon = "hacs:hacs"
|
||||
@@ -76,7 +83,7 @@ class HacsSystemEntity(HacsBaseEntity):
|
||||
return system_info(self.hacs)
|
||||
|
||||
|
||||
class HacsRepositoryEntity(HacsBaseEntity):
|
||||
class HacsRepositoryEntity(BaseCoordinatorEntity[HacsUpdateCoordinator], HacsBaseEntity):
|
||||
"""Base repository entity."""
|
||||
|
||||
def __init__(
|
||||
@@ -85,9 +92,11 @@ class HacsRepositoryEntity(HacsBaseEntity):
|
||||
repository: HacsRepository,
|
||||
) -> None:
|
||||
"""Initialize."""
|
||||
super().__init__(hacs=hacs)
|
||||
BaseCoordinatorEntity.__init__(self, hacs.coordinators[repository.data.category])
|
||||
HacsBaseEntity.__init__(self, hacs=hacs)
|
||||
self.repository = repository
|
||||
self._attr_unique_id = str(repository.data.id)
|
||||
self._repo_last_fetched = repository.data.last_fetched
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
@@ -100,20 +109,35 @@ class HacsRepositoryEntity(HacsBaseEntity):
|
||||
if self.repository.data.full_name == HacsGitHubRepo.INTEGRATION:
|
||||
return system_info(self.hacs)
|
||||
|
||||
def _manufacturer():
|
||||
if authors := self.repository.data.authors:
|
||||
return ", ".join(author.replace("@", "") for author in authors)
|
||||
return self.repository.data.full_name.split("/")[0]
|
||||
|
||||
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",
|
||||
"manufacturer": _manufacturer(),
|
||||
"configuration_url": f"homeassistant://hacs/repository/{self.repository.data.id}",
|
||||
"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()
|
||||
def _handle_coordinator_update(self) -> None:
|
||||
"""Handle updated data from the coordinator."""
|
||||
if (
|
||||
self._repo_last_fetched is not None
|
||||
and self.repository.data.last_fetched is not None
|
||||
and self._repo_last_fetched >= self.repository.data.last_fetched
|
||||
):
|
||||
return
|
||||
|
||||
self._repo_last_fetched = self.repository.data.last_fetched
|
||||
self.async_write_ha_state()
|
||||
|
||||
async def async_update(self) -> None:
|
||||
"""Update the entity.
|
||||
|
||||
Only used by the generic entity update service.
|
||||
"""
|
||||
|
||||
@@ -1,22 +1,23 @@
|
||||
"""Helper constants."""
|
||||
|
||||
# pylint: disable=missing-class-docstring
|
||||
from enum import Enum
|
||||
from enum import StrEnum
|
||||
|
||||
|
||||
class HacsGitHubRepo(str, Enum):
|
||||
class HacsGitHubRepo(StrEnum):
|
||||
"""HacsGitHubRepo."""
|
||||
|
||||
DEFAULT = "hacs/default"
|
||||
INTEGRATION = "hacs/integration"
|
||||
|
||||
|
||||
class HacsCategory(str, Enum):
|
||||
class HacsCategory(StrEnum):
|
||||
APPDAEMON = "appdaemon"
|
||||
INTEGRATION = "integration"
|
||||
LOVELACE = "lovelace"
|
||||
PLUGIN = "plugin" # Kept for legacy purposes
|
||||
NETDAEMON = "netdaemon"
|
||||
PYTHON_SCRIPT = "python_script"
|
||||
TEMPLATE = "template"
|
||||
THEME = "theme"
|
||||
REMOVED = "removed"
|
||||
|
||||
@@ -24,7 +25,7 @@ class HacsCategory(str, Enum):
|
||||
return str(self.value)
|
||||
|
||||
|
||||
class HacsDispatchEvent(str, Enum):
|
||||
class HacsDispatchEvent(StrEnum):
|
||||
"""HacsDispatchEvent."""
|
||||
|
||||
CONFIG = "hacs_dispatch_config"
|
||||
@@ -37,19 +38,14 @@ class HacsDispatchEvent(str, Enum):
|
||||
STATUS = "hacs_dispatch_status"
|
||||
|
||||
|
||||
class RepositoryFile(str, Enum):
|
||||
class RepositoryFile(StrEnum):
|
||||
"""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):
|
||||
class LovelaceMode(StrEnum):
|
||||
"""Lovelace Modes."""
|
||||
|
||||
STORAGE = "storage"
|
||||
@@ -58,7 +54,7 @@ class LovelaceMode(str, Enum):
|
||||
YAML = "yaml"
|
||||
|
||||
|
||||
class HacsStage(str, Enum):
|
||||
class HacsStage(StrEnum):
|
||||
SETUP = "setup"
|
||||
STARTUP = "startup"
|
||||
WAITING = "waiting"
|
||||
@@ -66,7 +62,7 @@ class HacsStage(str, Enum):
|
||||
BACKGROUND = "background"
|
||||
|
||||
|
||||
class HacsDisabledReason(str, Enum):
|
||||
class HacsDisabledReason(StrEnum):
|
||||
RATE_LIMIT = "rate_limit"
|
||||
REMOVED = "removed"
|
||||
INVALID_TOKEN = "invalid_token"
|
||||
|
||||
67
custom_components/hacs/frontend.py
Normal file
67
custom_components/hacs/frontend.py
Normal file
@@ -0,0 +1,67 @@
|
||||
"""Starting setup task: Frontend."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from homeassistant.components.frontend import (
|
||||
add_extra_js_url,
|
||||
async_register_built_in_panel,
|
||||
)
|
||||
|
||||
from .const import DOMAIN, URL_BASE
|
||||
from .hacs_frontend import VERSION as FE_VERSION, locate_dir
|
||||
from .utils.workarounds import async_register_static_path
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from .base import HacsBase
|
||||
|
||||
|
||||
async def async_register_frontend(hass: HomeAssistant, hacs: HacsBase) -> None:
|
||||
"""Register the frontend."""
|
||||
|
||||
# Register frontend
|
||||
if hacs.configuration.dev and (frontend_path := os.getenv("HACS_FRONTEND_DIR")):
|
||||
hacs.log.warning(
|
||||
"<HacsFrontend> Frontend development mode enabled. Do not run in production!"
|
||||
)
|
||||
await async_register_static_path(
|
||||
hass, f"{URL_BASE}/frontend", f"{frontend_path}/hacs_frontend", cache_headers=False
|
||||
)
|
||||
hacs.frontend_version = "dev"
|
||||
else:
|
||||
await async_register_static_path(
|
||||
hass, f"{URL_BASE}/frontend", locate_dir(), cache_headers=False
|
||||
)
|
||||
hacs.frontend_version = FE_VERSION
|
||||
|
||||
# Custom iconset
|
||||
await async_register_static_path(
|
||||
hass, f"{URL_BASE}/iconset.js", str(hacs.integration_dir / "iconset.js")
|
||||
)
|
||||
add_extra_js_url(hass, f"{URL_BASE}/iconset.js")
|
||||
|
||||
# Add to sidepanel if needed
|
||||
if DOMAIN not in hass.data.get("frontend_panels", {}):
|
||||
async_register_built_in_panel(
|
||||
hass,
|
||||
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,
|
||||
)
|
||||
|
||||
# Setup plugin endpoint if needed
|
||||
await hacs.async_setup_frontend_endpoint_plugin()
|
||||
@@ -1,10 +1 @@
|
||||
|
||||
try {
|
||||
new Function("import('/hacsfiles/frontend/main-150a7578.js')")();
|
||||
} catch (err) {
|
||||
var el = document.createElement('script');
|
||||
el.src = '/hacsfiles/frontend/main-150a7578.js';
|
||||
el.type = 'module';
|
||||
document.body.appendChild(el);
|
||||
}
|
||||
|
||||
!function(){function n(n){var e=document.createElement("script");e.src=n,document.body.appendChild(e)}if(/.*Version\/(?:11|12)(?:\.\d+)*.*Safari\//.test(navigator.userAgent))n("/hacsfiles/frontend/frontend_es5/entrypoint.c180d0b256f9b6d0.js");else try{new Function("import('/hacsfiles/frontend/frontend_latest/entrypoint.bb9d28f38e9fba76.js')")()}catch(e){n("/hacsfiles/frontend/frontend_es5/entrypoint.c180d0b256f9b6d0.js")}}()
|
||||
@@ -1,3 +0,0 @@
|
||||
{
|
||||
"./src/main.ts": "main-150a7578.js"
|
||||
}
|
||||
@@ -1 +1 @@
|
||||
VERSION="20220522162559"
|
||||
VERSION="20250128065759"
|
||||
@@ -1,4 +1,9 @@
|
||||
{
|
||||
"domain": "hacs",
|
||||
"name": "HACS",
|
||||
"after_dependencies": [
|
||||
"python_script"
|
||||
],
|
||||
"codeowners": [
|
||||
"@ludeeus"
|
||||
],
|
||||
@@ -8,15 +13,14 @@
|
||||
"websocket_api",
|
||||
"frontend",
|
||||
"persistent_notification",
|
||||
"lovelace"
|
||||
"lovelace",
|
||||
"repairs"
|
||||
],
|
||||
"documentation": "https://hacs.xyz/docs/configuration/start",
|
||||
"domain": "hacs",
|
||||
"documentation": "https://hacs.xyz/docs/use/",
|
||||
"iot_class": "cloud_polling",
|
||||
"issue_tracker": "https://github.com/hacs/integration/issues",
|
||||
"name": "HACS",
|
||||
"requirements": [
|
||||
"aiogithubapi>=22.2.4"
|
||||
"aiogithubapi>=22.10.1"
|
||||
],
|
||||
"version": "1.25.0"
|
||||
"version": "2.0.5"
|
||||
}
|
||||
@@ -1,20 +1,21 @@
|
||||
"""Initialize repositories."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from ..enums import HacsCategory
|
||||
from .appdaemon import HacsAppdaemonRepository
|
||||
from .base import HacsRepository
|
||||
from .integration import HacsIntegrationRepository
|
||||
from .netdaemon import HacsNetdaemonRepository
|
||||
from .plugin import HacsPluginRepository
|
||||
from .python_script import HacsPythonScriptRepository
|
||||
from .template import HacsTemplateRepository
|
||||
from .theme import HacsThemeRepository
|
||||
|
||||
RERPOSITORY_CLASSES: dict[HacsCategory, HacsRepository] = {
|
||||
REPOSITORY_CLASSES: dict[HacsCategory, HacsRepository] = {
|
||||
HacsCategory.THEME: HacsThemeRepository,
|
||||
HacsCategory.INTEGRATION: HacsIntegrationRepository,
|
||||
HacsCategory.PYTHON_SCRIPT: HacsPythonScriptRepository,
|
||||
HacsCategory.APPDAEMON: HacsAppdaemonRepository,
|
||||
HacsCategory.NETDAEMON: HacsNetdaemonRepository,
|
||||
HacsCategory.PLUGIN: HacsPluginRepository,
|
||||
HacsCategory.TEMPLATE: HacsTemplateRepository,
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Class for appdaemon apps in HACS."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
@@ -40,11 +41,11 @@ class HacsAppdaemonRepository(HacsRepository):
|
||||
addir = await self.repository_object.get_contents("apps", self.ref)
|
||||
except AIOGitHubAPIException:
|
||||
raise HacsException(
|
||||
f"Repository structure for {self.ref.replace('tags/','')} is not compliant"
|
||||
f"{self.string} Repository structure for {self.ref.replace('tags/','')} is not compliant"
|
||||
) from None
|
||||
|
||||
if not isinstance(addir, list):
|
||||
self.validate.errors.append("Repository structure not compliant")
|
||||
self.validate.errors.append(f"{self.string} Repository structure not compliant")
|
||||
|
||||
self.content.path.remote = addir[0].path
|
||||
self.content.objects = await self.repository_object.get_contents(
|
||||
@@ -79,7 +80,7 @@ class HacsAppdaemonRepository(HacsRepository):
|
||||
# Set local path
|
||||
self.content.path.local = self.localpath
|
||||
|
||||
# Signal entities to refresh
|
||||
# Signal frontend to refresh
|
||||
if self.data.installed:
|
||||
self.hacs.async_dispatch(
|
||||
HacsDispatchEvent.REPOSITORY,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,16 +1,19 @@
|
||||
"""Class for integrations in HACS."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
|
||||
from homeassistant.loader import async_get_custom_components
|
||||
|
||||
from ..const import DOMAIN
|
||||
from ..enums import HacsCategory, HacsDispatchEvent, HacsGitHubRepo, RepositoryFile
|
||||
from ..exceptions import AddonRepositoryException, HacsException
|
||||
from ..utils.decode import decode_content
|
||||
from ..utils.decorator import concurrent
|
||||
from ..utils.filters import get_first_directory_in_directory
|
||||
from ..utils.json import json_loads
|
||||
from .base import HacsRepository
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -36,12 +39,33 @@ class HacsIntegrationRepository(HacsRepository):
|
||||
|
||||
async def async_post_installation(self):
|
||||
"""Run post installation steps."""
|
||||
self.pending_restart = True
|
||||
if self.data.config_flow:
|
||||
if self.data.full_name != HacsGitHubRepo.INTEGRATION:
|
||||
await self.reload_custom_components()
|
||||
if self.data.first_install:
|
||||
self.pending_restart = False
|
||||
return
|
||||
|
||||
if self.pending_restart:
|
||||
self.logger.debug("%s Creating restart_required issue", self.string)
|
||||
async_create_issue(
|
||||
hass=self.hacs.hass,
|
||||
domain=DOMAIN,
|
||||
issue_id=f"restart_required_{self.data.id}_{self.ref}",
|
||||
is_fixable=True,
|
||||
issue_domain=self.data.domain or DOMAIN,
|
||||
severity=IssueSeverity.WARNING,
|
||||
translation_key="restart_required",
|
||||
translation_placeholders={
|
||||
"name": self.display_name,
|
||||
},
|
||||
)
|
||||
|
||||
async def async_post_uninstall(self) -> None:
|
||||
"""Run post uninstall steps."""
|
||||
if self.data.config_flow:
|
||||
await self.reload_custom_components()
|
||||
else:
|
||||
self.pending_restart = True
|
||||
|
||||
async def validate_repository(self):
|
||||
@@ -62,7 +86,8 @@ class HacsIntegrationRepository(HacsRepository):
|
||||
):
|
||||
raise AddonRepositoryException()
|
||||
raise HacsException(
|
||||
f"Repository structure for {self.ref.replace('tags/','')} is not compliant"
|
||||
f"{self.string} Repository structure for {
|
||||
self.ref.replace('tags/', '')} is not compliant"
|
||||
)
|
||||
self.content.path.remote = f"custom_components/{name}"
|
||||
|
||||
@@ -70,14 +95,15 @@ class HacsIntegrationRepository(HacsRepository):
|
||||
if manifest := await self.async_get_integration_manifest():
|
||||
try:
|
||||
self.integration_manifest = manifest
|
||||
self.data.authors = manifest["codeowners"]
|
||||
self.data.authors = manifest.get("codeowners", [])
|
||||
self.data.domain = manifest["domain"]
|
||||
self.data.manifest_name = manifest["name"]
|
||||
self.data.manifest_name = manifest.get("name")
|
||||
self.data.config_flow = manifest.get("config_flow", False)
|
||||
|
||||
except KeyError as exception:
|
||||
self.validate.errors.append(
|
||||
f"Missing expected key '{exception}' in { RepositoryFile.MAINIFEST_JSON}"
|
||||
f"Missing expected key '{exception}' in {
|
||||
RepositoryFile.MAINIFEST_JSON}"
|
||||
)
|
||||
self.hacs.log.error(
|
||||
"Missing expected key '%s' in '%s'", exception, RepositoryFile.MAINIFEST_JSON
|
||||
@@ -110,14 +136,15 @@ class HacsIntegrationRepository(HacsRepository):
|
||||
if manifest := await self.async_get_integration_manifest():
|
||||
try:
|
||||
self.integration_manifest = manifest
|
||||
self.data.authors = manifest["codeowners"]
|
||||
self.data.authors = manifest.get("codeowners", [])
|
||||
self.data.domain = manifest["domain"]
|
||||
self.data.manifest_name = manifest["name"]
|
||||
self.data.manifest_name = manifest.get("name")
|
||||
self.data.config_flow = manifest.get("config_flow", False)
|
||||
|
||||
except KeyError as exception:
|
||||
self.validate.errors.append(
|
||||
f"Missing expected key '{exception}' in { RepositoryFile.MAINIFEST_JSON}"
|
||||
f"Missing expected key '{exception}' in {
|
||||
RepositoryFile.MAINIFEST_JSON}"
|
||||
)
|
||||
self.hacs.log.error(
|
||||
"Missing expected key '%s' in '%s'", exception, RepositoryFile.MAINIFEST_JSON
|
||||
@@ -126,7 +153,7 @@ class HacsIntegrationRepository(HacsRepository):
|
||||
# Set local path
|
||||
self.content.path.local = self.localpath
|
||||
|
||||
# Signal entities to refresh
|
||||
# Signal frontend to refresh
|
||||
if self.data.installed:
|
||||
self.hacs.async_dispatch(
|
||||
HacsDispatchEvent.REPOSITORY,
|
||||
@@ -163,4 +190,28 @@ class HacsIntegrationRepository(HacsRepository):
|
||||
**{"params": {"ref": ref or self.version_to_download()}},
|
||||
)
|
||||
if response:
|
||||
return json.loads(decode_content(response.data.content))
|
||||
return json_loads(decode_content(response.data.content))
|
||||
|
||||
async def get_integration_manifest(self, *, version: str, **kwargs) -> dict[str, Any] | None:
|
||||
"""Get the content of the manifest.json file."""
|
||||
manifest_path = (
|
||||
"manifest.json"
|
||||
if self.repository_manifest.content_in_root
|
||||
else f"{self.content.path.remote}/{RepositoryFile.MAINIFEST_JSON}"
|
||||
)
|
||||
|
||||
if manifest_path not in (x.full_path for x in self.tree):
|
||||
raise HacsException(f"No {RepositoryFile.MAINIFEST_JSON} file found '{manifest_path}'")
|
||||
|
||||
self.logger.debug("%s Getting manifest.json for version=%s", self.string, version)
|
||||
try:
|
||||
result = await self.hacs.async_download_file(
|
||||
f"https://raw.githubusercontent.com/{
|
||||
self.data.full_name}/{version}/{manifest_path}",
|
||||
nolog=True,
|
||||
)
|
||||
if result is None:
|
||||
return None
|
||||
return json_loads(result)
|
||||
except Exception: # pylint: disable=broad-except
|
||||
return None
|
||||
|
||||
@@ -1,104 +0,0 @@
|
||||
"""Class for netdaemon apps in HACS."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from ..enums import HacsCategory, HacsDispatchEvent
|
||||
from ..exceptions import HacsException
|
||||
from ..utils import filters
|
||||
from ..utils.decorator import concurrent
|
||||
from .base import HacsRepository
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..base import HacsBase
|
||||
|
||||
|
||||
class HacsNetdaemonRepository(HacsRepository):
|
||||
"""Netdaemon apps in HACS."""
|
||||
|
||||
def __init__(self, hacs: HacsBase, full_name: str):
|
||||
"""Initialize."""
|
||||
super().__init__(hacs=hacs)
|
||||
self.data.full_name = full_name
|
||||
self.data.full_name_lower = full_name.lower()
|
||||
self.data.category = HacsCategory.NETDAEMON
|
||||
self.content.path.local = self.localpath
|
||||
self.content.path.remote = "apps"
|
||||
|
||||
@property
|
||||
def localpath(self):
|
||||
"""Return localpath."""
|
||||
return f"{self.hacs.core.config_path}/netdaemon/apps/{self.data.name}"
|
||||
|
||||
async def validate_repository(self):
|
||||
"""Validate."""
|
||||
await self.common_validate()
|
||||
|
||||
# Custom step 1: Validate content.
|
||||
if self.repository_manifest:
|
||||
if self.repository_manifest.content_in_root:
|
||||
self.content.path.remote = ""
|
||||
|
||||
if self.content.path.remote == "apps":
|
||||
self.data.domain = filters.get_first_directory_in_directory(
|
||||
self.tree, self.content.path.remote
|
||||
)
|
||||
self.content.path.remote = f"apps/{self.data.name}"
|
||||
|
||||
compliant = False
|
||||
for treefile in self.treefiles:
|
||||
if treefile.startswith(f"{self.content.path.remote}") and treefile.endswith(".cs"):
|
||||
compliant = True
|
||||
break
|
||||
if not compliant:
|
||||
raise HacsException(
|
||||
f"Repository structure for {self.ref.replace('tags/','')} is not compliant"
|
||||
)
|
||||
|
||||
# Handle potential errors
|
||||
if self.validate.errors:
|
||||
for error in self.validate.errors:
|
||||
if not self.hacs.status.startup:
|
||||
self.logger.error("%s %s", self.string, error)
|
||||
return self.validate.success
|
||||
|
||||
@concurrent(concurrenttasks=10, backoff_time=5)
|
||||
async def update_repository(self, ignore_issues=False, force=False):
|
||||
"""Update."""
|
||||
if not await self.common_update(ignore_issues, force) and not force:
|
||||
return
|
||||
|
||||
# Get appdaemon objects.
|
||||
if self.repository_manifest:
|
||||
if self.repository_manifest.content_in_root:
|
||||
self.content.path.remote = ""
|
||||
|
||||
if self.content.path.remote == "apps":
|
||||
self.data.domain = filters.get_first_directory_in_directory(
|
||||
self.tree, self.content.path.remote
|
||||
)
|
||||
self.content.path.remote = f"apps/{self.data.name}"
|
||||
|
||||
# Set local path
|
||||
self.content.path.local = self.localpath
|
||||
|
||||
# Signal entities to refresh
|
||||
if self.data.installed:
|
||||
self.hacs.async_dispatch(
|
||||
HacsDispatchEvent.REPOSITORY,
|
||||
{
|
||||
"id": 1337,
|
||||
"action": "update",
|
||||
"repository": self.data.full_name,
|
||||
"repository_id": self.data.id,
|
||||
},
|
||||
)
|
||||
|
||||
async def async_post_installation(self):
|
||||
"""Run post installation steps."""
|
||||
try:
|
||||
await self.hacs.hass.services.async_call(
|
||||
"hassio", "addon_restart", {"addon": "c6a2317c_netdaemon"}
|
||||
)
|
||||
except BaseException: # lgtm [py/catch-base-exception] pylint: disable=broad-except
|
||||
pass
|
||||
@@ -1,15 +1,21 @@
|
||||
"""Class for plugins in HACS."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from ..enums import HacsCategory, HacsDispatchEvent
|
||||
from ..exceptions import HacsException
|
||||
from ..utils.decorator import concurrent
|
||||
from ..utils.json import json_loads
|
||||
from .base import HacsRepository
|
||||
|
||||
HACSTAG_REPLACER = re.compile(r"\D+")
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from homeassistant.components.lovelace.resources import ResourceStorageCollection
|
||||
|
||||
from ..base import HacsBase
|
||||
|
||||
|
||||
@@ -40,7 +46,7 @@ class HacsPluginRepository(HacsRepository):
|
||||
|
||||
if self.content.path.remote is None:
|
||||
raise HacsException(
|
||||
f"Repository structure for {self.ref.replace('tags/','')} is not compliant"
|
||||
f"{self.string} Repository structure for {self.ref.replace('tags/','')} is not compliant"
|
||||
)
|
||||
|
||||
if self.content.path.remote == "release":
|
||||
@@ -53,6 +59,15 @@ class HacsPluginRepository(HacsRepository):
|
||||
self.logger.error("%s %s", self.string, error)
|
||||
return self.validate.success
|
||||
|
||||
async def async_post_installation(self):
|
||||
"""Run post installation steps."""
|
||||
await self.hacs.async_setup_frontend_endpoint_plugin()
|
||||
await self.update_dashboard_resources()
|
||||
|
||||
async def async_post_uninstall(self):
|
||||
"""Run post uninstall steps."""
|
||||
await self.remove_dashboard_resources()
|
||||
|
||||
@concurrent(concurrenttasks=10, backoff_time=5)
|
||||
async def update_repository(self, ignore_issues=False, force=False):
|
||||
"""Update."""
|
||||
@@ -64,13 +79,13 @@ class HacsPluginRepository(HacsRepository):
|
||||
|
||||
if self.content.path.remote is None:
|
||||
self.validate.errors.append(
|
||||
f"Repository structure for {self.ref.replace('tags/','')} is not compliant"
|
||||
f"{self.string} Repository structure for {self.ref.replace('tags/','')} is not compliant"
|
||||
)
|
||||
|
||||
if self.content.path.remote == "release":
|
||||
self.content.single = True
|
||||
|
||||
# Signal entities to refresh
|
||||
# Signal frontend to refresh
|
||||
if self.data.installed:
|
||||
self.hacs.async_dispatch(
|
||||
HacsDispatchEvent.REPOSITORY,
|
||||
@@ -86,7 +101,7 @@ class HacsPluginRepository(HacsRepository):
|
||||
"""Get package content."""
|
||||
try:
|
||||
package = await self.repository_object.get_contents("package.json", self.ref)
|
||||
package = json.loads(package.content)
|
||||
package = json_loads(package.content)
|
||||
|
||||
if package:
|
||||
self.data.authors = package["author"]
|
||||
@@ -95,13 +110,9 @@ class HacsPluginRepository(HacsRepository):
|
||||
|
||||
def update_filenames(self) -> None:
|
||||
"""Get the filename to target."""
|
||||
possible_locations = (
|
||||
("",) if self.repository_manifest.content_in_root else ("release", "dist", "")
|
||||
)
|
||||
|
||||
# Handler for plug requirement 3
|
||||
if self.repository_manifest.filename:
|
||||
valid_filenames = (self.repository_manifest.filename,)
|
||||
content_in_root = self.repository_manifest.content_in_root
|
||||
if specific_filename := self.repository_manifest.filename:
|
||||
valid_filenames = (specific_filename,)
|
||||
else:
|
||||
valid_filenames = (
|
||||
f"{self.data.name.replace('lovelace-', '')}.js",
|
||||
@@ -110,25 +121,126 @@ class HacsPluginRepository(HacsRepository):
|
||||
f"{self.data.name}-bundle.js",
|
||||
)
|
||||
|
||||
for location in possible_locations:
|
||||
if location == "release":
|
||||
if not self.releases.objects:
|
||||
continue
|
||||
if not content_in_root:
|
||||
if self.releases.objects:
|
||||
release = self.releases.objects[0]
|
||||
if not release.assets:
|
||||
continue
|
||||
asset = release.assets[0]
|
||||
for filename in valid_filenames:
|
||||
if filename == asset.name:
|
||||
self.data.file_name = filename
|
||||
self.content.path.remote = "release"
|
||||
break
|
||||
|
||||
else:
|
||||
for filename in valid_filenames:
|
||||
if f"{location+'/' if location else ''}{filename}" in [
|
||||
x.full_path for x in self.tree
|
||||
if release.assets:
|
||||
if assetnames := [
|
||||
filename
|
||||
for filename in valid_filenames
|
||||
for asset in release.assets
|
||||
if filename == asset.name
|
||||
]:
|
||||
self.data.file_name = assetnames[0]
|
||||
self.content.path.remote = "release"
|
||||
return
|
||||
|
||||
all_paths = {x.full_path for x in self.tree}
|
||||
for filename in valid_filenames:
|
||||
if filename in all_paths:
|
||||
self.data.file_name = filename
|
||||
self.content.path.remote = ""
|
||||
return
|
||||
if not content_in_root and f"dist/{filename}" in all_paths:
|
||||
self.data.file_name = filename.split("/")[-1]
|
||||
self.content.path.remote = location
|
||||
break
|
||||
self.content.path.remote = "dist"
|
||||
return
|
||||
|
||||
def generate_dashboard_resource_hacstag(self) -> str:
|
||||
"""Get the HACS tag used by dashboard resources."""
|
||||
version = (
|
||||
self.display_installed_version
|
||||
or self.data.selected_tag
|
||||
or self.display_available_version
|
||||
)
|
||||
return f"{self.data.id}{HACSTAG_REPLACER.sub('', version)}"
|
||||
|
||||
def generate_dashboard_resource_namespace(self) -> str:
|
||||
"""Get the dashboard resource namespace."""
|
||||
return f"/hacsfiles/{self.data.full_name.split("/")[1]}"
|
||||
|
||||
def generate_dashboard_resource_url(self) -> str:
|
||||
"""Get the dashboard resource namespace."""
|
||||
filename = self.data.file_name
|
||||
if "/" in filename:
|
||||
self.logger.warning("%s have defined an invalid file name %s", self.string, filename)
|
||||
filename = filename.split("/")[-1]
|
||||
return (
|
||||
f"{self.generate_dashboard_resource_namespace()}/{filename}"
|
||||
f"?hacstag={self.generate_dashboard_resource_hacstag()}"
|
||||
)
|
||||
|
||||
def _get_resource_handler(self) -> ResourceStorageCollection | None:
|
||||
"""Get the resource handler."""
|
||||
resources: ResourceStorageCollection | None
|
||||
if not (hass_data := self.hacs.hass.data):
|
||||
self.logger.error("%s Can not access the hass data", self.string)
|
||||
return
|
||||
|
||||
if (lovelace_data := hass_data.get("lovelace")) is None:
|
||||
self.logger.warning("%s Can not access the lovelace integration data", self.string)
|
||||
return
|
||||
|
||||
if self.hacs.core.ha_version > "2025.1.99":
|
||||
# Changed to 2025.2.0
|
||||
# Changed in https://github.com/home-assistant/core/pull/136313
|
||||
resources = lovelace_data.resources
|
||||
else:
|
||||
resources = lovelace_data.get("resources")
|
||||
|
||||
if resources is None:
|
||||
self.logger.warning("%s Can not access the dashboard resources", self.string)
|
||||
return
|
||||
|
||||
if not hasattr(resources, "store") or resources.store is None:
|
||||
self.logger.info("%s YAML mode detected, can not update resources", self.string)
|
||||
return
|
||||
|
||||
if resources.store.key != "lovelace_resources" or resources.store.version != 1:
|
||||
self.logger.warning("%s Can not use the dashboard resources", self.string)
|
||||
return
|
||||
|
||||
return resources
|
||||
|
||||
async def update_dashboard_resources(self) -> None:
|
||||
"""Update dashboard resources."""
|
||||
if not (resources := self._get_resource_handler()):
|
||||
return
|
||||
|
||||
if not resources.loaded:
|
||||
await resources.async_load()
|
||||
|
||||
namespace = self.generate_dashboard_resource_namespace()
|
||||
url = self.generate_dashboard_resource_url()
|
||||
|
||||
for entry in resources.async_items():
|
||||
if (entry_url := entry["url"]).startswith(namespace):
|
||||
if entry_url != url:
|
||||
self.logger.info(
|
||||
"%s Updating existing dashboard resource from %s to %s",
|
||||
self.string,
|
||||
entry_url,
|
||||
url,
|
||||
)
|
||||
await resources.async_update_item(entry["id"], {"url": url})
|
||||
return
|
||||
|
||||
# Nothing was updated, add the resource
|
||||
self.logger.info("%s Adding dashboard resource %s", self.string, url)
|
||||
await resources.async_create_item({"res_type": "module", "url": url})
|
||||
|
||||
async def remove_dashboard_resources(self) -> None:
|
||||
"""Remove dashboard resources."""
|
||||
if not (resources := self._get_resource_handler()):
|
||||
return
|
||||
|
||||
if not resources.loaded:
|
||||
await resources.async_load()
|
||||
|
||||
namespace = self.generate_dashboard_resource_namespace()
|
||||
|
||||
for entry in resources.async_items():
|
||||
if entry["url"].startswith(namespace):
|
||||
self.logger.info("%s Removing dashboard resource %s", self.string, entry["url"])
|
||||
await resources.async_delete_item(entry["id"])
|
||||
return
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Class for python_scripts in HACS."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
@@ -48,7 +49,7 @@ class HacsPythonScriptRepository(HacsRepository):
|
||||
break
|
||||
if not compliant:
|
||||
raise HacsException(
|
||||
f"Repository structure for {self.ref.replace('tags/','')} is not compliant"
|
||||
f"{self.string} Repository structure for {self.ref.replace('tags/','')} is not compliant"
|
||||
)
|
||||
|
||||
# Handle potential errors
|
||||
@@ -63,6 +64,9 @@ class HacsPythonScriptRepository(HacsRepository):
|
||||
# Set name
|
||||
self.update_filenames()
|
||||
|
||||
if self.hacs.system.action:
|
||||
await self.hacs.validation.async_run_repository_checks(self)
|
||||
|
||||
@concurrent(concurrenttasks=10, backoff_time=5)
|
||||
async def update_repository(self, ignore_issues=False, force=False):
|
||||
"""Update."""
|
||||
@@ -80,13 +84,13 @@ class HacsPythonScriptRepository(HacsRepository):
|
||||
break
|
||||
if not compliant:
|
||||
raise HacsException(
|
||||
f"Repository structure for {self.ref.replace('tags/','')} is not compliant"
|
||||
f"{self.string} Repository structure for {self.ref.replace('tags/','')} is not compliant"
|
||||
)
|
||||
|
||||
# Update name
|
||||
self.update_filenames()
|
||||
|
||||
# Signal entities to refresh
|
||||
# Signal frontend to refresh
|
||||
if self.data.installed:
|
||||
self.hacs.async_dispatch(
|
||||
HacsDispatchEvent.REPOSITORY,
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
"""Class for themes in HACS."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
|
||||
from ..enums import HacsCategory, HacsDispatchEvent
|
||||
from ..exceptions import HacsException
|
||||
from ..utils.decorator import concurrent
|
||||
@@ -32,10 +35,7 @@ class HacsThemeRepository(HacsRepository):
|
||||
|
||||
async def async_post_installation(self):
|
||||
"""Run post installation steps."""
|
||||
try:
|
||||
await self.hacs.hass.services.async_call("frontend", "reload_themes", {})
|
||||
except BaseException: # lgtm [py/catch-base-exception] pylint: disable=broad-except
|
||||
pass
|
||||
await self._reload_frontend_themes()
|
||||
|
||||
async def validate_repository(self):
|
||||
"""Validate."""
|
||||
@@ -50,7 +50,7 @@ class HacsThemeRepository(HacsRepository):
|
||||
break
|
||||
if not compliant:
|
||||
raise HacsException(
|
||||
f"Repository structure for {self.ref.replace('tags/','')} is not compliant"
|
||||
f"{self.string} Repository structure for {self.ref.replace('tags/','')} is not compliant"
|
||||
)
|
||||
|
||||
if self.repository_manifest.content_in_root:
|
||||
@@ -69,6 +69,21 @@ class HacsThemeRepository(HacsRepository):
|
||||
self.update_filenames()
|
||||
self.content.path.local = self.localpath
|
||||
|
||||
if self.hacs.system.action:
|
||||
await self.hacs.validation.async_run_repository_checks(self)
|
||||
|
||||
async def _reload_frontend_themes(self) -> None:
|
||||
"""Reload frontend themes."""
|
||||
self.logger.debug("%s Reloading frontend themes", self.string)
|
||||
try:
|
||||
await self.hacs.hass.services.async_call("frontend", "reload_themes", {})
|
||||
except HomeAssistantError as exception:
|
||||
self.logger.exception("%s %s", self.string, exception)
|
||||
|
||||
async def async_post_uninstall(self) -> None:
|
||||
"""Run post uninstall steps."""
|
||||
await self._reload_frontend_themes()
|
||||
|
||||
@concurrent(concurrenttasks=10, backoff_time=5)
|
||||
async def update_repository(self, ignore_issues=False, force=False):
|
||||
"""Update."""
|
||||
@@ -83,7 +98,7 @@ class HacsThemeRepository(HacsRepository):
|
||||
self.update_filenames()
|
||||
self.content.path.local = self.localpath
|
||||
|
||||
# Signal entities to refresh
|
||||
# Signal frontend to refresh
|
||||
if self.data.installed:
|
||||
self.hacs.async_dispatch(
|
||||
HacsDispatchEvent.REPOSITORY,
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
"""Sensor platform for HACS."""
|
||||
from __future__ import annotations
|
||||
|
||||
from homeassistant.components.sensor import SensorEntity
|
||||
from homeassistant.core import callback
|
||||
|
||||
from .const import DOMAIN
|
||||
from .entity import HacsSystemEntity
|
||||
from .enums import ConfigurationType
|
||||
|
||||
|
||||
async def async_setup_platform(hass, _config, async_add_entities, _discovery_info=None):
|
||||
"""Setup sensor platform."""
|
||||
async_add_entities([HACSSensor(hacs=hass.data.get(DOMAIN))])
|
||||
|
||||
|
||||
async def async_setup_entry(hass, _config_entry, async_add_devices):
|
||||
"""Setup sensor platform."""
|
||||
async_add_devices([HACSSensor(hacs=hass.data.get(DOMAIN))])
|
||||
|
||||
|
||||
class HACSSensor(HacsSystemEntity, SensorEntity):
|
||||
"""HACS Sensor class."""
|
||||
|
||||
_attr_name = "hacs"
|
||||
_attr_native_unit_of_measurement = "pending update(s)"
|
||||
_attr_native_value = None
|
||||
|
||||
@callback
|
||||
def _update(self) -> None:
|
||||
"""Update the sensor."""
|
||||
|
||||
repositories = [
|
||||
repository
|
||||
for repository in self.hacs.repositories.list_all
|
||||
if repository.pending_update
|
||||
]
|
||||
self._attr_native_value = len(repositories)
|
||||
if (
|
||||
self.hacs.configuration.config_type == ConfigurationType.YAML
|
||||
and not self.hacs.configuration.experimental
|
||||
):
|
||||
self._attr_extra_state_attributes = {
|
||||
"repositories": [
|
||||
{
|
||||
"name": repository.data.full_name,
|
||||
"display_name": repository.display_name,
|
||||
"installed_version": repository.display_installed_version,
|
||||
"available_version": repository.display_available_version,
|
||||
}
|
||||
for repository in repositories
|
||||
]
|
||||
}
|
||||
@@ -1,4 +1,7 @@
|
||||
"""Provide info to system health."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from aiogithubapi.common.const import BASE_API_URL
|
||||
from homeassistant.components import system_health
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
@@ -7,6 +10,7 @@ from .base import HacsBase
|
||||
from .const import DOMAIN
|
||||
|
||||
GITHUB_STATUS = "https://www.githubstatus.com/"
|
||||
CLOUDFLARE_STATUS = "https://www.cloudflarestatus.com/"
|
||||
|
||||
|
||||
@callback
|
||||
@@ -16,8 +20,11 @@ def async_register(hass: HomeAssistant, register: system_health.SystemHealthRegi
|
||||
register.async_register_info(system_health_info, "/hacs")
|
||||
|
||||
|
||||
async def system_health_info(hass):
|
||||
async def system_health_info(hass: HomeAssistant) -> dict[str, Any]:
|
||||
"""Get info for the info page."""
|
||||
if DOMAIN not in hass.data:
|
||||
return {"Disabled": "HACS is not loaded, but HA still requests this information..."}
|
||||
|
||||
hacs: HacsBase = hass.data[DOMAIN]
|
||||
response = await hacs.githubapi.rate_limit()
|
||||
|
||||
@@ -29,6 +36,9 @@ async def system_health_info(hass):
|
||||
"GitHub Web": system_health.async_check_can_reach_url(
|
||||
hass, "https://github.com/", GITHUB_STATUS
|
||||
),
|
||||
"HACS Data": system_health.async_check_can_reach_url(
|
||||
hass, "https://data-v2.hacs.xyz/data.json", CLOUDFLARE_STATUS
|
||||
),
|
||||
"GitHub API Calls Remaining": response.data.resources.core.remaining,
|
||||
"Installed Version": hacs.version,
|
||||
"Stage": hacs.stage,
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
"data": {
|
||||
"acc_logs": "I know how to access Home Assistant logs",
|
||||
"acc_addons": "I know that there are no add-ons in HACS",
|
||||
"acc_untested": "I know that everything inside HACS is custom and untested by Home Assistant",
|
||||
"acc_untested": "I know that everything inside HACS including HACS itself is custom and untested by Home Assistant",
|
||||
"acc_disable": "I know that if I get issues with Home Assistant I should disable all my custom_components"
|
||||
},
|
||||
"description": "Before you can setup HACS you need to acknowledge the following"
|
||||
@@ -30,28 +30,55 @@
|
||||
}
|
||||
},
|
||||
"progress": {
|
||||
"wait_for_device": "1. Open {url} \n2.Paste the following key to authorize HACS: \n```\n{code}\n```\n"
|
||||
"wait_for_device": "1. Open {url} \n2. Paste the following key to authorize HACS: \n```\n{code}\n```"
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
"abort": {
|
||||
"not_setup": "HACS is not setup.",
|
||||
"release_limit_value": "The release limit needs to be between 1 and 100"
|
||||
"pending_tasks": "There are pending tasks. Try again later.",
|
||||
"release_limit_value": "The release limit needs to be between 1 and 100."
|
||||
},
|
||||
"step": {
|
||||
"user": {
|
||||
"data": {
|
||||
"not_in_use": "Not in use with YAML",
|
||||
"country": "Filter with country code.",
|
||||
"experimental": "Enable experimental features",
|
||||
"release_limit": "Number of releases to show.",
|
||||
"debug": "Enable debug.",
|
||||
"country": "Filter with country code",
|
||||
"release_limit": "Number of releases to show",
|
||||
"debug": "Enable debug",
|
||||
"appdaemon": "Enable AppDaemon apps discovery & tracking",
|
||||
"netdaemon": "Enable NetDaemon apps discovery & tracking",
|
||||
"sidepanel_icon": "Side panel icon",
|
||||
"sidepanel_title": "Side panel title"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"issues": {
|
||||
"restart_required": {
|
||||
"title": "Restart required",
|
||||
"fix_flow": {
|
||||
"step": {
|
||||
"confirm_restart": {
|
||||
"title": "Restart required",
|
||||
"description": "Restart of Home Assistant is required to finish download/update of {name}, click submit to restart now."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"removed": {
|
||||
"title": "Repository removed from HACS",
|
||||
"description": "Because {reason}, `{name}` has been removed from HACS. Please visit the [HACS Panel](/hacs/repository/{repositry_id}) to remove it."
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
"switch": {
|
||||
"pre-release": {
|
||||
"name": "Pre-release",
|
||||
"state": {
|
||||
"off": "No pre-releases",
|
||||
"on": "Pre-releases preferred"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,22 +1,28 @@
|
||||
"""Update entities for HACS."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.components.update import UpdateEntity
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.components.update import UpdateEntity, UpdateEntityFeature
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant, HomeAssistantError, callback
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
|
||||
from .base import HacsBase
|
||||
from .const import DOMAIN
|
||||
from .entity import HacsRepositoryEntity
|
||||
from .enums import HacsCategory, HacsDispatchEvent
|
||||
from .exceptions import HacsException
|
||||
|
||||
|
||||
async def async_setup_entry(hass, _config_entry, async_add_devices):
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
|
||||
) -> None:
|
||||
"""Setup update platform."""
|
||||
hacs: HacsBase = hass.data.get(DOMAIN)
|
||||
async_add_devices(
|
||||
hacs: HacsBase = hass.data[DOMAIN]
|
||||
async_add_entities(
|
||||
HacsRepositoryUpdateEntity(hacs=hacs, repository=repository)
|
||||
for repository in hacs.repositories.list_downloaded
|
||||
)
|
||||
@@ -25,13 +31,12 @@ async def async_setup_entry(hass, _config_entry, async_add_devices):
|
||||
class HacsRepositoryUpdateEntity(HacsRepositoryEntity, UpdateEntity):
|
||||
"""Update entities for repositories downloaded with HACS."""
|
||||
|
||||
@property
|
||||
def supported_features(self) -> int | None:
|
||||
"""Return the supported features of the entity."""
|
||||
features = 4 | 16
|
||||
if self.repository.can_download:
|
||||
features = features | 1
|
||||
return features
|
||||
_attr_supported_features = (
|
||||
UpdateEntityFeature.INSTALL
|
||||
| UpdateEntityFeature.SPECIFIC_VERSION
|
||||
| UpdateEntityFeature.PROGRESS
|
||||
| UpdateEntityFeature.RELEASE_NOTES
|
||||
)
|
||||
|
||||
@property
|
||||
def name(self) -> str | None:
|
||||
@@ -58,8 +63,6 @@ class HacsRepositoryUpdateEntity(HacsRepositoryEntity, UpdateEntity):
|
||||
@property
|
||||
def release_summary(self) -> str | None:
|
||||
"""Return the release summary."""
|
||||
if not self.repository.can_download:
|
||||
return f"<ha-alert alert-type='warning'>Requires Home Assistant {self.repository.repository_manifest.homeassistant}</ha-alert>"
|
||||
if self.repository.pending_restart:
|
||||
return "<ha-alert alert-type='error'>Restart of Home Assistant required</ha-alert>"
|
||||
return None
|
||||
@@ -77,23 +80,44 @@ class HacsRepositoryUpdateEntity(HacsRepositoryEntity, UpdateEntity):
|
||||
|
||||
async def async_install(self, version: str | None, backup: bool, **kwargs: Any) -> None:
|
||||
"""Install an update."""
|
||||
if self.repository.display_version_or_commit == "version":
|
||||
self._update_in_progress(progress=10)
|
||||
self.repository.data.selected_tag = self.latest_version
|
||||
await self.repository.update_repository(force=True)
|
||||
self._update_in_progress(progress=20)
|
||||
await self.repository.async_install()
|
||||
self._update_in_progress(progress=False)
|
||||
to_download = version or self.latest_version
|
||||
if to_download == self.installed_version:
|
||||
raise HomeAssistantError(f"Version {self.installed_version} of {
|
||||
self.repository.data.full_name} is already downloaded")
|
||||
try:
|
||||
await self.repository.async_download_repository(ref=version or self.latest_version)
|
||||
except HacsException as exception:
|
||||
raise HomeAssistantError(exception) from exception
|
||||
|
||||
async def async_release_notes(self) -> str | None:
|
||||
"""Return the release notes."""
|
||||
if self.repository.pending_restart or not self.repository.can_download:
|
||||
if self.repository.pending_restart:
|
||||
return None
|
||||
|
||||
if self.latest_version not in self.repository.data.published_tags:
|
||||
releases = await self.repository.get_releases(
|
||||
prerelease=self.repository.data.show_beta,
|
||||
returnlimit=self.hacs.configuration.release_limit,
|
||||
)
|
||||
if releases:
|
||||
self.repository.data.releases = True
|
||||
self.repository.releases.objects = releases
|
||||
self.repository.data.published_tags = [x.tag_name for x in releases]
|
||||
self.repository.data.last_version = next(iter(self.repository.data.published_tags))
|
||||
|
||||
release_notes = ""
|
||||
if len(self.repository.releases.objects) > 0:
|
||||
release = self.repository.releases.objects[0]
|
||||
release_notes += release.body
|
||||
# Compile release notes from installed version up to the latest
|
||||
if self.installed_version in self.repository.data.published_tags:
|
||||
for release in self.repository.releases.objects:
|
||||
if release.tag_name == self.installed_version:
|
||||
break
|
||||
release_notes += f"# {release.tag_name}"
|
||||
if release.tag_name != release.name:
|
||||
release_notes += f" - {release.name}"
|
||||
release_notes += f"\n\n{release.body}"
|
||||
release_notes += "\n\n---\n\n"
|
||||
elif any(self.repository.releases.objects):
|
||||
release_notes += self.repository.releases.objects[0].body
|
||||
|
||||
if self.repository.pending_update:
|
||||
if self.repository.data.category == HacsCategory.INTEGRATION:
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Backup."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
@@ -27,7 +28,7 @@ class Backup:
|
||||
backup_path: str = DEFAULT_BACKUP_PATH,
|
||||
repository: HacsRepository | None = None,
|
||||
) -> None:
|
||||
"""initialize."""
|
||||
"""Initialize."""
|
||||
self.hacs = hacs
|
||||
self.repository = repository
|
||||
self.local_path = local_path or repository.content.path.local
|
||||
@@ -74,7 +75,9 @@ class Backup:
|
||||
self.local_path,
|
||||
self.backup_path_full,
|
||||
)
|
||||
except BaseException as exception: # lgtm [py/catch-base-exception] pylint: disable=broad-except
|
||||
except (
|
||||
BaseException # lgtm [py/catch-base-exception] pylint: disable=broad-except
|
||||
) as exception:
|
||||
self.hacs.log.warning("Could not create backup: %s", exception)
|
||||
|
||||
def restore(self) -> None:
|
||||
@@ -105,33 +108,3 @@ class Backup:
|
||||
while os.path.exists(self.backup_path):
|
||||
sleep(0.1)
|
||||
self.hacs.log.debug("Backup dir %s cleared", self.backup_path)
|
||||
|
||||
|
||||
class BackupNetDaemon(Backup):
|
||||
"""BackupNetDaemon."""
|
||||
|
||||
def create(self) -> None:
|
||||
"""Create a backup in /tmp"""
|
||||
if not self._init_backup_dir():
|
||||
return
|
||||
|
||||
for filename in os.listdir(self.repository.content.path.local):
|
||||
if not filename.endswith(".yaml"):
|
||||
continue
|
||||
|
||||
source_file_name = f"{self.repository.content.path.local}/{filename}"
|
||||
target_file_name = f"{self.backup_path}/{filename}"
|
||||
shutil.copyfile(source_file_name, target_file_name)
|
||||
|
||||
def restore(self) -> None:
|
||||
"""Create a backup in /tmp"""
|
||||
if not os.path.exists(self.backup_path):
|
||||
return
|
||||
|
||||
for filename in os.listdir(self.backup_path):
|
||||
if not filename.endswith(".yaml"):
|
||||
continue
|
||||
|
||||
source_file_name = f"{self.backup_path}/{filename}"
|
||||
target_file_name = f"{self.repository.content.path.local}/{filename}"
|
||||
shutil.copyfile(source_file_name, target_file_name)
|
||||
|
||||
@@ -1,74 +1,9 @@
|
||||
"""HACS Configuration Schemas."""
|
||||
# pylint: disable=dangerous-default-value
|
||||
import voluptuous as vol
|
||||
|
||||
from ..const import LOCALE
|
||||
|
||||
# Configuration:
|
||||
TOKEN = "token"
|
||||
SIDEPANEL_TITLE = "sidepanel_title"
|
||||
SIDEPANEL_ICON = "sidepanel_icon"
|
||||
FRONTEND_REPO = "frontend_repo"
|
||||
FRONTEND_REPO_URL = "frontend_repo_url"
|
||||
APPDAEMON = "appdaemon"
|
||||
NETDAEMON = "netdaemon"
|
||||
|
||||
# Options:
|
||||
COUNTRY = "country"
|
||||
DEBUG = "debug"
|
||||
RELEASE_LIMIT = "release_limit"
|
||||
EXPERIMENTAL = "experimental"
|
||||
|
||||
# Config group
|
||||
PATH_OR_URL = "frontend_repo_path_or_url"
|
||||
|
||||
|
||||
def hacs_base_config_schema(config: dict = {}) -> dict:
|
||||
"""Return a shcema configuration dict for HACS."""
|
||||
if not config:
|
||||
config = {
|
||||
TOKEN: "xxxxxxxxxxxxxxxxxxxxxxxxxxx",
|
||||
}
|
||||
return {
|
||||
vol.Required(TOKEN, default=config.get(TOKEN)): str,
|
||||
}
|
||||
|
||||
|
||||
def hacs_config_option_schema(options: dict = {}) -> dict:
|
||||
"""Return a shcema for HACS configuration options."""
|
||||
if not options:
|
||||
options = {
|
||||
APPDAEMON: False,
|
||||
COUNTRY: "ALL",
|
||||
DEBUG: False,
|
||||
EXPERIMENTAL: False,
|
||||
NETDAEMON: False,
|
||||
RELEASE_LIMIT: 5,
|
||||
SIDEPANEL_ICON: "hacs:hacs",
|
||||
SIDEPANEL_TITLE: "HACS",
|
||||
FRONTEND_REPO: "",
|
||||
FRONTEND_REPO_URL: "",
|
||||
}
|
||||
return {
|
||||
vol.Optional(SIDEPANEL_TITLE, default=options.get(SIDEPANEL_TITLE)): str,
|
||||
vol.Optional(SIDEPANEL_ICON, default=options.get(SIDEPANEL_ICON)): str,
|
||||
vol.Optional(RELEASE_LIMIT, default=options.get(RELEASE_LIMIT)): int,
|
||||
vol.Optional(COUNTRY, default=options.get(COUNTRY)): vol.In(LOCALE),
|
||||
vol.Optional(APPDAEMON, default=options.get(APPDAEMON)): bool,
|
||||
vol.Optional(NETDAEMON, default=options.get(NETDAEMON)): bool,
|
||||
vol.Optional(DEBUG, default=options.get(DEBUG)): bool,
|
||||
vol.Optional(EXPERIMENTAL, default=options.get(EXPERIMENTAL)): bool,
|
||||
vol.Exclusive(FRONTEND_REPO, PATH_OR_URL): str,
|
||||
vol.Exclusive(FRONTEND_REPO_URL, PATH_OR_URL): str,
|
||||
}
|
||||
|
||||
|
||||
def hacs_config_combined() -> dict:
|
||||
"""Combine the configuration options."""
|
||||
base = hacs_base_config_schema()
|
||||
options = hacs_config_option_schema()
|
||||
|
||||
for option in options:
|
||||
base[option] = options[option]
|
||||
|
||||
return base
|
||||
|
||||
@@ -1,37 +1,45 @@
|
||||
"""Data handler for HACS."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from datetime import datetime
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.util import json as json_util
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
|
||||
from ..base import HacsBase
|
||||
from ..enums import HacsDispatchEvent, HacsGitHubRepo
|
||||
from ..const import HACS_REPOSITORY_ID
|
||||
from ..enums import HacsDisabledReason, HacsDispatchEvent
|
||||
from ..repositories.base import TOPIC_FILTER, HacsManifest, HacsRepository
|
||||
from .logger import get_hacs_logger
|
||||
from .logger import LOGGER
|
||||
from .path import is_safe
|
||||
from .store import async_load_from_store, async_save_to_store
|
||||
|
||||
DEFAULT_BASE_REPOSITORY_DATA = (
|
||||
EXPORTED_BASE_DATA = (
|
||||
("new", False),
|
||||
("full_name", ""),
|
||||
)
|
||||
|
||||
EXPORTED_REPOSITORY_DATA = EXPORTED_BASE_DATA + (
|
||||
("authors", []),
|
||||
("category", ""),
|
||||
("description", ""),
|
||||
("domain", None),
|
||||
("downloads", 0),
|
||||
("etag_repository", None),
|
||||
("full_name", ""),
|
||||
("last_updated", 0),
|
||||
("hide", False),
|
||||
("last_updated", 0),
|
||||
("new", False),
|
||||
("stargazers_count", 0),
|
||||
("topics", []),
|
||||
)
|
||||
|
||||
DEFAULT_EXTENDED_REPOSITORY_DATA = (
|
||||
EXPORTED_DOWNLOADED_REPOSITORY_DATA = EXPORTED_REPOSITORY_DATA + (
|
||||
("archived", False),
|
||||
("config_flow", False),
|
||||
("default_branch", None),
|
||||
("description", ""),
|
||||
("first_install", False),
|
||||
("installed_commit", None),
|
||||
("installed", False),
|
||||
@@ -39,13 +47,11 @@ DEFAULT_EXTENDED_REPOSITORY_DATA = (
|
||||
("last_version", None),
|
||||
("manifest_name", None),
|
||||
("open_issues", 0),
|
||||
("prerelease", None),
|
||||
("published_tags", []),
|
||||
("pushed_at", ""),
|
||||
("releases", False),
|
||||
("selected_tag", None),
|
||||
("show_beta", False),
|
||||
("stargazers_count", 0),
|
||||
("topics", []),
|
||||
)
|
||||
|
||||
|
||||
@@ -54,7 +60,7 @@ class HacsData:
|
||||
|
||||
def __init__(self, hacs: HacsBase):
|
||||
"""Initialize."""
|
||||
self.logger = get_hacs_logger()
|
||||
self.logger = LOGGER
|
||||
self.hacs = hacs
|
||||
self.content = {}
|
||||
|
||||
@@ -79,6 +85,7 @@ class HacsData:
|
||||
"ignored_repositories": self.hacs.common.ignored_repositories,
|
||||
},
|
||||
)
|
||||
await self._async_store_experimental_content_and_repos()
|
||||
await self._async_store_content_and_repos()
|
||||
|
||||
async def _async_store_content_and_repos(self, _=None): # bb: ignore
|
||||
@@ -93,46 +100,96 @@ class HacsData:
|
||||
for event in (HacsDispatchEvent.REPOSITORY, HacsDispatchEvent.CONFIG):
|
||||
self.hacs.async_dispatch(event, {})
|
||||
|
||||
async def _async_store_experimental_content_and_repos(self, _=None):
|
||||
"""Store the main repos file and each repo that is out of date."""
|
||||
# Repositories
|
||||
self.content = {}
|
||||
for repository in self.hacs.repositories.list_all:
|
||||
if repository.data.category in self.hacs.common.categories:
|
||||
self.async_store_experimental_repository_data(repository)
|
||||
|
||||
await async_save_to_store(self.hacs.hass, "data", {"repositories": self.content})
|
||||
|
||||
@callback
|
||||
def async_store_repository_data(self, repository: HacsRepository) -> dict:
|
||||
"""Store the repository data."""
|
||||
data = {"repository_manifest": repository.repository_manifest.manifest}
|
||||
|
||||
for key, default_value in DEFAULT_BASE_REPOSITORY_DATA:
|
||||
if (value := repository.data.__getattribute__(key)) != default_value:
|
||||
for key, default in (
|
||||
EXPORTED_DOWNLOADED_REPOSITORY_DATA
|
||||
if repository.data.installed
|
||||
else EXPORTED_REPOSITORY_DATA
|
||||
):
|
||||
if (value := getattr(repository.data, key, default)) != default:
|
||||
data[key] = value
|
||||
|
||||
if repository.data.installed:
|
||||
for key, default_value in DEFAULT_EXTENDED_REPOSITORY_DATA:
|
||||
if (value := repository.data.__getattribute__(key)) != default_value:
|
||||
data[key] = value
|
||||
if repository.data.installed_version:
|
||||
data["version_installed"] = repository.data.installed_version
|
||||
|
||||
if repository.data.last_fetched:
|
||||
data["last_fetched"] = repository.data.last_fetched.timestamp()
|
||||
|
||||
self.content[str(repository.data.id)] = data
|
||||
|
||||
@callback
|
||||
def async_store_experimental_repository_data(self, repository: HacsRepository) -> None:
|
||||
"""Store the experimental repository data for non downloaded repositories."""
|
||||
data = {}
|
||||
self.content.setdefault(repository.data.category, [])
|
||||
|
||||
if repository.data.installed:
|
||||
data["repository_manifest"] = repository.repository_manifest.manifest
|
||||
for key, default in EXPORTED_DOWNLOADED_REPOSITORY_DATA:
|
||||
if (value := getattr(repository.data, key, default)) != default:
|
||||
data[key] = value
|
||||
|
||||
if repository.data.installed_version:
|
||||
data["version_installed"] = repository.data.installed_version
|
||||
if repository.data.last_fetched:
|
||||
data["last_fetched"] = repository.data.last_fetched.timestamp()
|
||||
else:
|
||||
for key, default in EXPORTED_BASE_DATA:
|
||||
if (value := getattr(repository.data, key, default)) != default:
|
||||
data[key] = value
|
||||
|
||||
self.content[repository.data.category].append({"id": str(repository.data.id), **data})
|
||||
|
||||
async def restore(self):
|
||||
"""Restore saved data."""
|
||||
self.hacs.status.new = False
|
||||
repositories = {}
|
||||
hacs = {}
|
||||
|
||||
try:
|
||||
hacs = await async_load_from_store(self.hacs.hass, "hacs") or {}
|
||||
repositories = await async_load_from_store(self.hacs.hass, "repositories") or {}
|
||||
except HomeAssistantError:
|
||||
pass
|
||||
|
||||
try:
|
||||
repositories = await async_load_from_store(self.hacs.hass, "repositories")
|
||||
if not repositories and (data := await async_load_from_store(self.hacs.hass, "data")):
|
||||
for category, entries in data.get("repositories", {}).items():
|
||||
for repository in entries:
|
||||
repositories[repository["id"]] = {"category": category, **repository}
|
||||
|
||||
except HomeAssistantError as exception:
|
||||
self.hacs.log.error(
|
||||
"Could not read %s, restore the file from a backup - %s",
|
||||
self.hacs.hass.config.path(".storage/hacs.data"),
|
||||
exception,
|
||||
)
|
||||
self.hacs.disable_hacs(HacsDisabledReason.RESTORE)
|
||||
return False
|
||||
|
||||
if not hacs and not repositories:
|
||||
# Assume new install
|
||||
self.hacs.status.new = True
|
||||
self.logger.info("<HacsData restore> Loading base repository information")
|
||||
repositories = await self.hacs.hass.async_add_executor_job(
|
||||
json_util.load_json,
|
||||
f"{self.hacs.core.config_path}/custom_components/hacs/utils/default.repositories",
|
||||
)
|
||||
return True
|
||||
|
||||
self.logger.info("<HacsData restore> Restore started")
|
||||
|
||||
# Hacs
|
||||
self.hacs.common.archived_repositories = []
|
||||
self.hacs.common.ignored_repositories = []
|
||||
self.hacs.common.archived_repositories = set()
|
||||
self.hacs.common.ignored_repositories = set()
|
||||
self.hacs.common.renamed_repositories = {}
|
||||
|
||||
# Clear out doubble renamed values
|
||||
@@ -143,14 +200,14 @@ class HacsData:
|
||||
self.hacs.common.renamed_repositories[entry] = value
|
||||
|
||||
# Clear out doubble archived values
|
||||
for entry in hacs.get("archived_repositories", []):
|
||||
for entry in hacs.get("archived_repositories", set()):
|
||||
if entry not in self.hacs.common.archived_repositories:
|
||||
self.hacs.common.archived_repositories.append(entry)
|
||||
self.hacs.common.archived_repositories.add(entry)
|
||||
|
||||
# Clear out doubble ignored values
|
||||
for entry in hacs.get("ignored_repositories", []):
|
||||
for entry in hacs.get("ignored_repositories", set()):
|
||||
if entry not in self.hacs.common.ignored_repositories:
|
||||
self.hacs.common.ignored_repositories.append(entry)
|
||||
self.hacs.common.ignored_repositories.add(entry)
|
||||
|
||||
try:
|
||||
await self.register_unknown_repositories(repositories)
|
||||
@@ -165,41 +222,64 @@ class HacsData:
|
||||
self.async_restore_repository(entry, repo_data)
|
||||
|
||||
self.logger.info("<HacsData restore> Restore done")
|
||||
except BaseException as exception: # lgtm [py/catch-base-exception] pylint: disable=broad-except
|
||||
except (
|
||||
# lgtm [py/catch-base-exception] pylint: disable=broad-except
|
||||
BaseException
|
||||
) as exception:
|
||||
self.logger.critical(
|
||||
"<HacsData restore> [%s] Restore Failed!", exception, exc_info=exception
|
||||
)
|
||||
return False
|
||||
return True
|
||||
|
||||
async def register_unknown_repositories(self, repositories):
|
||||
async def register_unknown_repositories(
|
||||
self, repositories: dict[str, dict[str, Any]], category: str | None = None
|
||||
):
|
||||
"""Registry any unknown repositories."""
|
||||
register_tasks = [
|
||||
self.hacs.async_register_repository(
|
||||
for repo_idx, (entry, repo_data) in enumerate(repositories.items()):
|
||||
# async_register_repository is awaited in a loop
|
||||
# since its unlikely to ever suspend at startup
|
||||
if (
|
||||
entry == "0"
|
||||
or repo_data.get("category", category) is None
|
||||
or self.hacs.repositories.is_registered(repository_id=entry)
|
||||
):
|
||||
continue
|
||||
await self.hacs.async_register_repository(
|
||||
repository_full_name=repo_data["full_name"],
|
||||
category=repo_data["category"],
|
||||
category=repo_data.get("category", category),
|
||||
check=False,
|
||||
repository_id=entry,
|
||||
)
|
||||
for entry, repo_data in repositories.items()
|
||||
if entry != "0" and not self.hacs.repositories.is_registered(repository_id=entry)
|
||||
]
|
||||
if register_tasks:
|
||||
await asyncio.gather(*register_tasks)
|
||||
if repo_idx % 100 == 0:
|
||||
# yield to avoid blocking the event loop
|
||||
await asyncio.sleep(0)
|
||||
|
||||
@callback
|
||||
def async_restore_repository(self, entry, repository_data):
|
||||
def async_restore_repository(self, entry: str, repository_data: dict[str, Any]):
|
||||
"""Restore repository."""
|
||||
full_name = repository_data["full_name"]
|
||||
if not (repository := self.hacs.repositories.get_by_full_name(full_name)):
|
||||
self.logger.error("<HacsData restore> Did not find %s (%s)", full_name, entry)
|
||||
repository: HacsRepository | None = None
|
||||
if full_name := repository_data.get("full_name"):
|
||||
repository = self.hacs.repositories.get_by_full_name(full_name)
|
||||
if not repository:
|
||||
repository = self.hacs.repositories.get_by_id(entry)
|
||||
if not repository:
|
||||
return
|
||||
# Restore repository attributes
|
||||
|
||||
try:
|
||||
self.hacs.repositories.set_repository_id(repository, entry)
|
||||
except ValueError as exception:
|
||||
self.logger.warning("<HacsData async_restore_repository> duplicate IDs %s", exception)
|
||||
return
|
||||
|
||||
# Restore repository attributes
|
||||
repository.data.authors = repository_data.get("authors", [])
|
||||
repository.data.description = repository_data.get("description", "")
|
||||
repository.data.downloads = repository_data.get("downloads", 0)
|
||||
repository.data.last_updated = repository_data.get("last_updated", 0)
|
||||
if self.hacs.system.generator:
|
||||
repository.data.etag_releases = repository_data.get("etag_releases")
|
||||
repository.data.open_issues = repository_data.get("open_issues", 0)
|
||||
repository.data.etag_repository = repository_data.get("etag_repository")
|
||||
repository.data.topics = [
|
||||
topic for topic in repository_data.get("topics", []) if topic not in TOPIC_FILTER
|
||||
@@ -210,24 +290,27 @@ class HacsData:
|
||||
) or repository_data.get("stars", 0)
|
||||
repository.releases.last_release = repository_data.get("last_release_tag")
|
||||
repository.data.releases = repository_data.get("releases", False)
|
||||
repository.data.hide = repository_data.get("hide", False)
|
||||
repository.data.installed = repository_data.get("installed", False)
|
||||
repository.data.new = repository_data.get("new", False)
|
||||
repository.data.selected_tag = repository_data.get("selected_tag")
|
||||
repository.data.show_beta = repository_data.get("show_beta", False)
|
||||
repository.data.last_version = repository_data.get("last_release_tag")
|
||||
repository.data.last_version = repository_data.get("last_version")
|
||||
repository.data.prerelease = repository_data.get("prerelease")
|
||||
repository.data.last_commit = repository_data.get("last_commit")
|
||||
repository.data.installed_version = repository_data.get("version_installed")
|
||||
repository.data.installed_commit = repository_data.get("installed_commit")
|
||||
repository.data.manifest_name = repository_data.get("manifest_name")
|
||||
|
||||
if last_fetched := repository_data.get("last_fetched"):
|
||||
repository.data.last_fetched = datetime.fromtimestamp(last_fetched)
|
||||
repository.data.last_fetched = datetime.fromtimestamp(last_fetched, UTC)
|
||||
|
||||
repository.repository_manifest = HacsManifest.from_dict(
|
||||
repository_data.get("repository_manifest", {})
|
||||
repository_data.get("manifest") or repository_data.get("repository_manifest") or {}
|
||||
)
|
||||
|
||||
if repository.data.prerelease == repository.data.last_version:
|
||||
repository.data.prerelease = None
|
||||
|
||||
if repository.localpath is not None and is_safe(self.hacs, repository.localpath):
|
||||
# Set local path
|
||||
repository.content.path.local = repository.localpath
|
||||
@@ -235,6 +318,6 @@ class HacsData:
|
||||
if repository.data.installed:
|
||||
repository.data.first_install = False
|
||||
|
||||
if full_name == HacsGitHubRepo.INTEGRATION:
|
||||
if entry == HACS_REPOSITORY_ID:
|
||||
repository.data.installed_version = self.hacs.version
|
||||
repository.data.installed = True
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Util to decode content from the github API."""
|
||||
|
||||
from base64 import b64decode
|
||||
|
||||
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
"""HACS Decorators."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Coroutine
|
||||
from functools import wraps
|
||||
from typing import TYPE_CHECKING, Any, Coroutine
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from ..const import DEFAULT_CONCURRENT_BACKOFF_TIME, DEFAULT_CONCURRENT_TASKS
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1,4 +1,5 @@
|
||||
"""Filter functions."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
"""Custom logger for HACS."""
|
||||
|
||||
import logging
|
||||
|
||||
from ..const import PACKAGE_NAME
|
||||
|
||||
_HACSLogger: logging.Logger = logging.getLogger(PACKAGE_NAME)
|
||||
|
||||
|
||||
def get_hacs_logger() -> logging.Logger:
|
||||
"""Return a Logger instance."""
|
||||
return _HACSLogger
|
||||
LOGGER: logging.Logger = logging.getLogger(PACKAGE_NAME)
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
"""Path utils"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
@@ -8,13 +10,32 @@ if TYPE_CHECKING:
|
||||
from ..base import HacsBase
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def _get_safe_paths(
|
||||
config_path: str,
|
||||
appdaemon_path: str,
|
||||
plugin_path: str,
|
||||
python_script_path: str,
|
||||
theme_path: str,
|
||||
) -> set[str]:
|
||||
"""Get safe paths."""
|
||||
return {
|
||||
Path(f"{config_path}/{appdaemon_path}").as_posix(),
|
||||
Path(f"{config_path}/{plugin_path}").as_posix(),
|
||||
Path(f"{config_path}/{python_script_path}").as_posix(),
|
||||
Path(f"{config_path}/{theme_path}").as_posix(),
|
||||
Path(f"{config_path}/custom_components/").as_posix(),
|
||||
Path(f"{config_path}/custom_templates/").as_posix(),
|
||||
}
|
||||
|
||||
|
||||
def is_safe(hacs: HacsBase, path: str | Path) -> bool:
|
||||
"""Helper to check if path is safe to remove."""
|
||||
return Path(path).as_posix() not in (
|
||||
Path(f"{hacs.core.config_path}/{hacs.configuration.appdaemon_path}").as_posix(),
|
||||
Path(f"{hacs.core.config_path}/{hacs.configuration.netdaemon_path}").as_posix(),
|
||||
Path(f"{hacs.core.config_path}/{hacs.configuration.plugin_path}").as_posix(),
|
||||
Path(f"{hacs.core.config_path}/{hacs.configuration.python_script_path}").as_posix(),
|
||||
Path(f"{hacs.core.config_path}/{hacs.configuration.theme_path}").as_posix(),
|
||||
Path(f"{hacs.core.config_path}/custom_components/").as_posix(),
|
||||
configuration = hacs.configuration
|
||||
return Path(path).as_posix() not in _get_safe_paths(
|
||||
hacs.core.config_path,
|
||||
configuration.appdaemon_path,
|
||||
configuration.plugin_path,
|
||||
configuration.python_script_path,
|
||||
configuration.theme_path,
|
||||
)
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
"""The QueueManager class."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Coroutine
|
||||
import time
|
||||
from typing import Coroutine
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from ..exceptions import HacsExecutionStillInProgress
|
||||
from .logger import get_hacs_logger
|
||||
from .logger import LOGGER
|
||||
|
||||
_LOGGER = get_hacs_logger()
|
||||
_LOGGER = LOGGER
|
||||
|
||||
|
||||
class QueueManager:
|
||||
@@ -60,9 +61,6 @@ class QueueManager:
|
||||
for task in self.queue:
|
||||
local_queue.append(task)
|
||||
|
||||
for task in local_queue:
|
||||
self.queue.remove(task)
|
||||
|
||||
_LOGGER.debug("<QueueManager> Starting queue execution for %s tasks", len(local_queue))
|
||||
start = time.time()
|
||||
result = await asyncio.gather(*local_queue, return_exceptions=True)
|
||||
@@ -71,6 +69,9 @@ class QueueManager:
|
||||
_LOGGER.error("<QueueManager> %s", entry)
|
||||
end = time.time() - start
|
||||
|
||||
for task in local_queue:
|
||||
self.queue.remove(task)
|
||||
|
||||
_LOGGER.debug(
|
||||
"<QueueManager> Queue execution finished for %s tasks finished in %.2f seconds",
|
||||
len(local_queue),
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Regex utils"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
"""Storage handers."""
|
||||
|
||||
from homeassistant.helpers.json import JSONEncoder
|
||||
from homeassistant.helpers.storage import Store
|
||||
from homeassistant.util import json as json_util
|
||||
|
||||
from ..const import VERSION_STORAGE
|
||||
from ..exceptions import HacsException
|
||||
from .logger import get_hacs_logger
|
||||
from .logger import LOGGER
|
||||
|
||||
_LOGGER = get_hacs_logger()
|
||||
_LOGGER = LOGGER
|
||||
|
||||
|
||||
class HACSStore(Store):
|
||||
@@ -17,7 +18,9 @@ class HACSStore(Store):
|
||||
"""Load the data from disk if version matches."""
|
||||
try:
|
||||
data = json_util.load_json(self.path)
|
||||
except BaseException as exception: # lgtm [py/catch-base-exception] pylint: disable=broad-except
|
||||
except (
|
||||
BaseException # lgtm [py/catch-base-exception] pylint: disable=broad-except
|
||||
) as exception:
|
||||
_LOGGER.critical(
|
||||
"Could not load '%s', restore it from a backup or delete the file: %s",
|
||||
self.path,
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
"""Custom template support."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from jinja2 import Template
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..repositories.base import HacsRepository
|
||||
|
||||
|
||||
def render_template(content: str, context: HacsRepository) -> str:
|
||||
"""Render templates in content."""
|
||||
# Fix None issues
|
||||
if context.releases.last_release_object is not None:
|
||||
prerelease = context.releases.last_release_object.prerelease
|
||||
else:
|
||||
prerelease = False
|
||||
|
||||
# Render the template
|
||||
try:
|
||||
return Template(content).render(
|
||||
installed=context.data.installed,
|
||||
pending_update=context.pending_update,
|
||||
prerelease=prerelease,
|
||||
selected_tag=context.data.selected_tag,
|
||||
version_available=context.releases.last_release,
|
||||
version_installed=context.display_installed_version,
|
||||
)
|
||||
except BaseException as exception: # lgtm [py/catch-base-exception] pylint: disable=broad-except
|
||||
context.logger.debug(exception)
|
||||
return content
|
||||
@@ -1,6 +1,16 @@
|
||||
"""Validation utilities."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
from awesomeversion import AwesomeVersion
|
||||
from homeassistant.helpers.config_validation import url as url_validator
|
||||
import voluptuous as vol
|
||||
|
||||
from ..const import LOCALE
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -13,3 +23,193 @@ class Validate:
|
||||
def success(self) -> bool:
|
||||
"""Return bool if the validation was a success."""
|
||||
return len(self.errors) == 0
|
||||
|
||||
|
||||
def _country_validator(values) -> list[str]:
|
||||
"""Custom country validator."""
|
||||
countries = []
|
||||
if isinstance(values, str):
|
||||
countries.append(values.upper())
|
||||
elif isinstance(values, list):
|
||||
for value in values:
|
||||
countries.append(value.upper())
|
||||
else:
|
||||
raise vol.Invalid(f"Value '{values}' is not a string or list.", path=["country"])
|
||||
|
||||
for country in countries:
|
||||
if country not in LOCALE:
|
||||
raise vol.Invalid(f"Value '{country}' is not in {LOCALE}.", path=["country"])
|
||||
|
||||
return countries
|
||||
|
||||
|
||||
HACS_MANIFEST_JSON_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Optional("content_in_root"): bool,
|
||||
vol.Optional("country"): _country_validator,
|
||||
vol.Optional("filename"): str,
|
||||
vol.Optional("hacs"): str,
|
||||
vol.Optional("hide_default_branch"): bool,
|
||||
vol.Optional("homeassistant"): str,
|
||||
vol.Optional("persistent_directory"): str,
|
||||
vol.Optional("render_readme"): bool,
|
||||
vol.Optional("zip_release"): bool,
|
||||
vol.Required("name"): str,
|
||||
},
|
||||
extra=vol.PREVENT_EXTRA,
|
||||
)
|
||||
|
||||
INTEGRATION_MANIFEST_JSON_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required("codeowners"): list,
|
||||
vol.Required("documentation"): url_validator,
|
||||
vol.Required("domain"): str,
|
||||
vol.Required("issue_tracker"): url_validator,
|
||||
vol.Required("name"): str,
|
||||
vol.Required("version"): vol.Coerce(AwesomeVersion),
|
||||
},
|
||||
extra=vol.ALLOW_EXTRA,
|
||||
)
|
||||
|
||||
|
||||
def validate_repo_data(schema: dict[str, Any], extra: int) -> Callable[[Any], Any]:
|
||||
"""Return a validator for repo data.
|
||||
|
||||
This is used instead of vol.All to always try both the repo schema and
|
||||
and the validate_version validator.
|
||||
"""
|
||||
_schema = vol.Schema(schema, extra=extra)
|
||||
|
||||
def validate_repo_data(data: Any) -> Any:
|
||||
"""Validate integration repo data."""
|
||||
schema_errors: vol.MultipleInvalid | None = None
|
||||
try:
|
||||
_schema(data)
|
||||
except vol.MultipleInvalid as err:
|
||||
schema_errors = err
|
||||
try:
|
||||
validate_version(data)
|
||||
except vol.Invalid as err:
|
||||
if schema_errors:
|
||||
schema_errors.add(err)
|
||||
else:
|
||||
raise
|
||||
if schema_errors:
|
||||
raise schema_errors
|
||||
return data
|
||||
|
||||
return validate_repo_data
|
||||
|
||||
|
||||
def validate_version(data: Any) -> Any:
|
||||
"""Ensure at least one of last_commit or last_version is present."""
|
||||
if "last_commit" not in data and "last_version" not in data:
|
||||
raise vol.Invalid("Expected at least one of [`last_commit`, `last_version`], got none")
|
||||
return data
|
||||
|
||||
|
||||
V2_COMMON_DATA_JSON_SCHEMA = {
|
||||
vol.Required("description"): vol.Any(str, None),
|
||||
vol.Optional("downloads"): int,
|
||||
vol.Optional("etag_releases"): str,
|
||||
vol.Required("etag_repository"): str,
|
||||
vol.Required("full_name"): str,
|
||||
vol.Optional("last_commit"): str,
|
||||
vol.Required("last_fetched"): vol.Any(int, float),
|
||||
vol.Required("last_updated"): str,
|
||||
vol.Optional("last_version"): str,
|
||||
vol.Optional("prerelease"): str,
|
||||
vol.Required("manifest"): {
|
||||
vol.Optional("country"): vol.Any([str], False),
|
||||
vol.Optional("name"): str,
|
||||
},
|
||||
vol.Optional("open_issues"): int,
|
||||
vol.Optional("stargazers_count"): int,
|
||||
vol.Optional("topics"): [str],
|
||||
}
|
||||
|
||||
V2_INTEGRATION_DATA_JSON_SCHEMA = {
|
||||
**V2_COMMON_DATA_JSON_SCHEMA,
|
||||
vol.Required("domain"): str,
|
||||
vol.Required("manifest_name"): str,
|
||||
}
|
||||
|
||||
_V2_REPO_SCHEMAS = {
|
||||
"appdaemon": V2_COMMON_DATA_JSON_SCHEMA,
|
||||
"integration": V2_INTEGRATION_DATA_JSON_SCHEMA,
|
||||
"plugin": V2_COMMON_DATA_JSON_SCHEMA,
|
||||
"python_script": V2_COMMON_DATA_JSON_SCHEMA,
|
||||
"template": V2_COMMON_DATA_JSON_SCHEMA,
|
||||
"theme": V2_COMMON_DATA_JSON_SCHEMA,
|
||||
}
|
||||
|
||||
# Used when validating repos in the hacs integration, discards extra keys
|
||||
VALIDATE_FETCHED_V2_REPO_DATA = {
|
||||
category: validate_repo_data(schema, vol.REMOVE_EXTRA)
|
||||
for category, schema in _V2_REPO_SCHEMAS.items()
|
||||
}
|
||||
|
||||
# Used when validating repos when generating data, fails on extra keys
|
||||
VALIDATE_GENERATED_V2_REPO_DATA = {
|
||||
category: vol.Schema({str: validate_repo_data(schema, vol.PREVENT_EXTRA)})
|
||||
for category, schema in _V2_REPO_SCHEMAS.items()
|
||||
}
|
||||
|
||||
V2_CRITICAL_REPO_DATA_SCHEMA = {
|
||||
vol.Required("link"): str,
|
||||
vol.Required("reason"): str,
|
||||
vol.Required("repository"): str,
|
||||
}
|
||||
|
||||
# Used when validating critical repos in the hacs integration, discards extra keys
|
||||
VALIDATE_FETCHED_V2_CRITICAL_REPO_SCHEMA = vol.Schema(
|
||||
V2_CRITICAL_REPO_DATA_SCHEMA,
|
||||
extra=vol.REMOVE_EXTRA,
|
||||
)
|
||||
|
||||
# Used when validating critical repos when generating data, fails on extra keys
|
||||
VALIDATE_GENERATED_V2_CRITICAL_REPO_SCHEMA = vol.Schema(
|
||||
[
|
||||
vol.Schema(
|
||||
V2_CRITICAL_REPO_DATA_SCHEMA,
|
||||
extra=vol.PREVENT_EXTRA,
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
V2_REMOVED_REPO_DATA_SCHEMA = {
|
||||
vol.Optional("link"): str,
|
||||
vol.Optional("reason"): str,
|
||||
vol.Required("removal_type"): vol.In(
|
||||
[
|
||||
"Integration is missing a version, and is abandoned.",
|
||||
"Remove",
|
||||
"archived",
|
||||
"blacklist",
|
||||
"critical",
|
||||
"deprecated",
|
||||
"removal",
|
||||
"remove",
|
||||
"removed",
|
||||
"replaced",
|
||||
"repository",
|
||||
]
|
||||
),
|
||||
vol.Required("repository"): str,
|
||||
}
|
||||
|
||||
# Used when validating removed repos in the hacs integration, discards extra keys
|
||||
VALIDATE_FETCHED_V2_REMOVED_REPO_SCHEMA = vol.Schema(
|
||||
V2_REMOVED_REPO_DATA_SCHEMA,
|
||||
extra=vol.REMOVE_EXTRA,
|
||||
)
|
||||
|
||||
# Used when validating removed repos when generating data, fails on extra keys
|
||||
VALIDATE_GENERATED_V2_REMOVED_REPO_SCHEMA = vol.Schema(
|
||||
[
|
||||
vol.Schema(
|
||||
V2_REMOVED_REPO_DATA_SCHEMA,
|
||||
extra=vol.PREVENT_EXTRA,
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Version utils."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from functools import lru_cache
|
||||
@@ -21,7 +22,7 @@ def version_left_higher_then_right(left: str, right: str) -> bool | None:
|
||||
and right_version.strategy != AwesomeVersionStrategy.UNKNOWN
|
||||
):
|
||||
return left_version > right_version
|
||||
except (AwesomeVersionException, AttributeError):
|
||||
except (AwesomeVersionException, AttributeError, KeyError):
|
||||
pass
|
||||
|
||||
return None
|
||||
|
||||
@@ -1,7 +1,37 @@
|
||||
"""Workarounds for issues that should not be fixed."""
|
||||
"""Workarounds."""
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
DOMAIN_OVERRIDES = {
|
||||
# https://github.com/hacs/integration/issues/2465
|
||||
"custom-components/sensor.custom_aftership": "custom_aftership"
|
||||
}
|
||||
|
||||
|
||||
try:
|
||||
from homeassistant.components.http import StaticPathConfig
|
||||
|
||||
async def async_register_static_path(
|
||||
hass: HomeAssistant,
|
||||
url_path: str,
|
||||
path: str,
|
||||
cache_headers: bool = True,
|
||||
) -> None:
|
||||
"""Register a static path with the HTTP component."""
|
||||
await hass.http.async_register_static_paths(
|
||||
[StaticPathConfig(url_path, path, cache_headers)]
|
||||
)
|
||||
except ImportError:
|
||||
|
||||
async def async_register_static_path(
|
||||
hass: HomeAssistant,
|
||||
url_path: str,
|
||||
path: str,
|
||||
cache_headers: bool = True,
|
||||
) -> None:
|
||||
"""Register a static path with the HTTP component.
|
||||
|
||||
Legacy: Can be removed when min version is 2024.7
|
||||
https://developers.home-assistant.io/blog/2024/06/18/async_register_static_paths/
|
||||
"""
|
||||
hass.http.register_static_path(url_path, path, cache_headers)
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from ..enums import RepositoryFile
|
||||
from ..repositories.base import HacsRepository
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from .base import ActionValidationBase, ValidationException
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..repositories.base import HacsRepository
|
||||
|
||||
|
||||
async def async_setup_validator(repository: HacsRepository) -> Validator:
|
||||
"""Set up this validator."""
|
||||
@@ -13,7 +16,10 @@ async def async_setup_validator(repository: HacsRepository) -> Validator:
|
||||
class Validator(ActionValidationBase):
|
||||
"""Validate the repository."""
|
||||
|
||||
async def async_validate(self):
|
||||
more_info = "https://hacs.xyz/docs/publish/include#check-archived"
|
||||
allow_fork = False
|
||||
|
||||
async def async_validate(self) -> None:
|
||||
"""Validate the repository."""
|
||||
if RepositoryFile.HACS_JSON not in [x.filename for x in self.repository.tree]:
|
||||
raise ValidationException(f"The repository has no '{RepositoryFile.HACS_JSON}' file")
|
||||
if self.repository.data.archived:
|
||||
raise ValidationException("The repository is archived")
|
||||
@@ -1,12 +1,13 @@
|
||||
"""Base class for validation."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from time import monotonic
|
||||
from typing import TYPE_CHECKING
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from ..exceptions import HacsException
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..enums import HacsCategory
|
||||
from ..repositories.base import HacsRepository
|
||||
|
||||
|
||||
@@ -17,7 +18,9 @@ class ValidationException(HacsException):
|
||||
class ActionValidationBase:
|
||||
"""Base class for action validation."""
|
||||
|
||||
category: str = "common"
|
||||
categories: tuple[HacsCategory, ...] = ()
|
||||
allow_fork: bool = True
|
||||
more_info: str = "https://hacs.xyz/docs/publish/action"
|
||||
|
||||
def __init__(self, repository: HacsRepository) -> None:
|
||||
self.hacs = repository.hacs
|
||||
@@ -32,20 +35,20 @@ class ActionValidationBase:
|
||||
async def async_validate(self) -> None:
|
||||
"""Validate the repository."""
|
||||
|
||||
async def execute_validation(self, *_, **__) -> None:
|
||||
async def execute_validation(self, *_: Any, **__: Any) -> None:
|
||||
"""Execute the task defined in subclass."""
|
||||
self.hacs.log.info("<Validation %s> Starting validation", self.slug)
|
||||
|
||||
start_time = monotonic()
|
||||
self.failed = False
|
||||
|
||||
try:
|
||||
await self.async_validate()
|
||||
except ValidationException as exception:
|
||||
self.failed = True
|
||||
self.hacs.log.error("<Validation %s> failed: %s", self.slug, exception)
|
||||
self.hacs.log.error(
|
||||
"<Validation %s> failed: %s (More info: %s )",
|
||||
self.slug,
|
||||
exception,
|
||||
self.more_info,
|
||||
)
|
||||
|
||||
else:
|
||||
self.hacs.log.debug(
|
||||
"<Validation %s> took %.3f seconds to complete", self.slug, monotonic() - start_time
|
||||
)
|
||||
self.hacs.log.info("<Validation %s> completed", self.slug)
|
||||
|
||||
35
custom_components/hacs/validate/brands.py
Normal file
35
custom_components/hacs/validate/brands.py
Normal file
@@ -0,0 +1,35 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from custom_components.hacs.enums import HacsCategory
|
||||
|
||||
from .base import ActionValidationBase, ValidationException
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..repositories.base import HacsRepository
|
||||
|
||||
URL = "https://brands.home-assistant.io/domains.json"
|
||||
|
||||
|
||||
async def async_setup_validator(repository: HacsRepository) -> Validator:
|
||||
"""Set up this validator."""
|
||||
return Validator(repository=repository)
|
||||
|
||||
|
||||
class Validator(ActionValidationBase):
|
||||
"""Validate the repository."""
|
||||
|
||||
more_info = "https://hacs.xyz/docs/publish/include#check-brands"
|
||||
categories = (HacsCategory.INTEGRATION,)
|
||||
|
||||
async def async_validate(self) -> None:
|
||||
"""Validate the repository."""
|
||||
|
||||
response = await self.hacs.session.get(URL)
|
||||
content = await response.json()
|
||||
|
||||
if self.repository.data.domain not in content["custom"]:
|
||||
raise ValidationException(
|
||||
"The repository has not been added as a custom domain to the brands repo"
|
||||
)
|
||||
@@ -1,8 +1,12 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from ..repositories.base import HacsRepository
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from .base import ActionValidationBase, ValidationException
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..repositories.base import HacsRepository
|
||||
|
||||
|
||||
async def async_setup_validator(repository: HacsRepository) -> Validator:
|
||||
"""Set up this validator."""
|
||||
@@ -12,7 +16,10 @@ async def async_setup_validator(repository: HacsRepository) -> Validator:
|
||||
class Validator(ActionValidationBase):
|
||||
"""Validate the repository."""
|
||||
|
||||
async def async_validate(self):
|
||||
more_info = "https://hacs.xyz/docs/publish/include#check-repository"
|
||||
allow_fork = False
|
||||
|
||||
async def async_validate(self) -> None:
|
||||
"""Validate the repository."""
|
||||
if not self.repository.data.description:
|
||||
raise ValidationException("The repository has no description")
|
||||
35
custom_components/hacs/validate/hacsjson.py
Normal file
35
custom_components/hacs/validate/hacsjson.py
Normal file
@@ -0,0 +1,35 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from voluptuous.error import Invalid
|
||||
from voluptuous.humanize import humanize_error
|
||||
|
||||
from ..enums import HacsCategory, RepositoryFile
|
||||
from ..repositories.base import HacsManifest, HacsRepository
|
||||
from ..utils.validate import HACS_MANIFEST_JSON_SCHEMA
|
||||
from .base import ActionValidationBase, ValidationException
|
||||
|
||||
|
||||
async def async_setup_validator(repository: HacsRepository) -> Validator:
|
||||
"""Set up this validator."""
|
||||
return Validator(repository=repository)
|
||||
|
||||
|
||||
class Validator(ActionValidationBase):
|
||||
"""Validate the repository."""
|
||||
|
||||
more_info = "https://hacs.xyz/docs/publish/include#check-hacs-manifest"
|
||||
|
||||
async def async_validate(self) -> None:
|
||||
"""Validate the repository."""
|
||||
if RepositoryFile.HACS_JSON not in [x.filename for x in self.repository.tree]:
|
||||
raise ValidationException(f"The repository has no '{RepositoryFile.HACS_JSON}' file")
|
||||
|
||||
content = await self.repository.async_get_hacs_json(self.repository.ref)
|
||||
try:
|
||||
hacsjson = HacsManifest.from_dict(HACS_MANIFEST_JSON_SCHEMA(content))
|
||||
except Invalid as exception:
|
||||
raise ValidationException(humanize_error(content, exception)) from exception
|
||||
|
||||
if self.repository.data.category == HacsCategory.INTEGRATION:
|
||||
if hacsjson.zip_release and not hacsjson.filename:
|
||||
raise ValidationException("zip_release is True, but filename is not set")
|
||||
33
custom_components/hacs/validate/images.py
Normal file
33
custom_components/hacs/validate/images.py
Normal file
@@ -0,0 +1,33 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from ..enums import HacsCategory
|
||||
from .base import ActionValidationBase, ValidationException
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..repositories.base import HacsRepository
|
||||
|
||||
IGNORED = ["-shield", "img.shields.io", "buymeacoffee.com"]
|
||||
|
||||
|
||||
async def async_setup_validator(repository: HacsRepository) -> Validator:
|
||||
"""Set up this validator."""
|
||||
return Validator(repository=repository)
|
||||
|
||||
|
||||
class Validator(ActionValidationBase):
|
||||
"""Validate the repository."""
|
||||
|
||||
categories = (HacsCategory.PLUGIN, HacsCategory.THEME)
|
||||
more_info = "https://hacs.xyz/docs/publish/include#check-images"
|
||||
|
||||
async def async_validate(self) -> None:
|
||||
"""Validate the repository."""
|
||||
info = await self.repository.async_get_info_file_contents(version=self.repository.ref)
|
||||
for line in info.split("\n"):
|
||||
if "<img" in line or "![" in line:
|
||||
if [ignore for ignore in IGNORED if ignore in line]:
|
||||
continue
|
||||
return
|
||||
raise ValidationException("The repository does not have images in the Readme file")
|
||||
@@ -1,8 +1,12 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from ..repositories.base import HacsRepository
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from .base import ActionValidationBase, ValidationException
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..repositories.base import HacsRepository
|
||||
|
||||
|
||||
async def async_setup_validator(repository: HacsRepository) -> Validator:
|
||||
"""Set up this validator."""
|
||||
@@ -12,7 +16,9 @@ async def async_setup_validator(repository: HacsRepository) -> Validator:
|
||||
class Validator(ActionValidationBase):
|
||||
"""Validate the repository."""
|
||||
|
||||
async def async_validate(self):
|
||||
more_info = "https://hacs.xyz/docs/publish/include#check-info"
|
||||
|
||||
async def async_validate(self) -> None:
|
||||
"""Validate the repository."""
|
||||
filenames = [x.filename.lower() for x in self.repository.tree]
|
||||
if "readme" in filenames:
|
||||
@@ -1,9 +1,17 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from ..enums import RepositoryFile
|
||||
from ..repositories.base import HacsRepository
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from voluptuous.error import Invalid
|
||||
|
||||
from ..enums import HacsCategory, RepositoryFile
|
||||
from ..utils.validate import INTEGRATION_MANIFEST_JSON_SCHEMA
|
||||
from .base import ActionValidationBase, ValidationException
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..repositories.base import HacsRepository
|
||||
from ..repositories.integration import HacsIntegrationRepository
|
||||
|
||||
|
||||
async def async_setup_validator(repository: HacsRepository) -> Validator:
|
||||
"""Set up this validator."""
|
||||
@@ -13,11 +21,19 @@ async def async_setup_validator(repository: HacsRepository) -> Validator:
|
||||
class Validator(ActionValidationBase):
|
||||
"""Validate the repository."""
|
||||
|
||||
category = "integration"
|
||||
repository: HacsIntegrationRepository
|
||||
more_info = "https://hacs.xyz/docs/publish/include#check-manifest"
|
||||
categories = (HacsCategory.INTEGRATION,)
|
||||
|
||||
async def async_validate(self):
|
||||
async def async_validate(self) -> None:
|
||||
"""Validate the repository."""
|
||||
if RepositoryFile.MAINIFEST_JSON not in [x.filename for x in self.repository.tree]:
|
||||
raise ValidationException(
|
||||
f"The repository has no '{RepositoryFile.MAINIFEST_JSON}' file"
|
||||
)
|
||||
|
||||
content = await self.repository.get_integration_manifest(version=self.repository.ref)
|
||||
try:
|
||||
INTEGRATION_MANIFEST_JSON_SCHEMA(content)
|
||||
except Invalid as exception:
|
||||
raise ValidationException(exception) from exception
|
||||
|
||||
25
custom_components/hacs/validate/issues.py
Normal file
25
custom_components/hacs/validate/issues.py
Normal file
@@ -0,0 +1,25 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from .base import ActionValidationBase, ValidationException
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..repositories.base import HacsRepository
|
||||
|
||||
|
||||
async def async_setup_validator(repository: HacsRepository) -> Validator:
|
||||
"""Set up this validator."""
|
||||
return Validator(repository=repository)
|
||||
|
||||
|
||||
class Validator(ActionValidationBase):
|
||||
"""Validate the repository."""
|
||||
|
||||
more_info = "https://hacs.xyz/docs/publish/include#check-repository"
|
||||
allow_fork = False
|
||||
|
||||
async def async_validate(self) -> None:
|
||||
"""Validate the repository."""
|
||||
if not self.repository.data.has_issues:
|
||||
raise ValidationException("The repository does not have issues enabled")
|
||||
@@ -1,19 +1,19 @@
|
||||
"""Hacs validation manager."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from importlib import import_module
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from custom_components.hacs.repositories.base import HacsRepository
|
||||
|
||||
from .base import ActionValidationBase
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..base import HacsBase
|
||||
from ..repositories.base import HacsRepository
|
||||
from .base import ActionValidationBase
|
||||
|
||||
|
||||
class ValidationManager:
|
||||
@@ -23,16 +23,16 @@ class ValidationManager:
|
||||
"""Initialize the setup manager class."""
|
||||
self.hacs = hacs
|
||||
self.hass = hass
|
||||
self._validatiors: dict[str, ActionValidationBase] = {}
|
||||
self._validators: dict[str, ActionValidationBase] = {}
|
||||
|
||||
@property
|
||||
def validatiors(self) -> dict[str, ActionValidationBase]:
|
||||
def validators(self) -> list[ActionValidationBase]:
|
||||
"""Return all list of all tasks."""
|
||||
return list(self._validatiors.values())
|
||||
return list(self._validators.values())
|
||||
|
||||
async def async_load(self, repository: HacsRepository) -> None:
|
||||
"""Load all tasks."""
|
||||
self._validatiors = {}
|
||||
self._validators = {}
|
||||
validator_files = Path(__file__).parent
|
||||
validator_modules = (
|
||||
module.stem
|
||||
@@ -40,13 +40,12 @@ class ValidationManager:
|
||||
if module.name not in ("base.py", "__init__.py", "manager.py")
|
||||
)
|
||||
|
||||
async def _load_module(module: str):
|
||||
async def _load_module(module: str) -> None:
|
||||
task_module = import_module(f"{__package__}.{module}")
|
||||
if task := await task_module.async_setup_validator(repository=repository):
|
||||
self._validatiors[task.slug] = task
|
||||
self._validators[task.slug] = task
|
||||
|
||||
await asyncio.gather(*[_load_module(task) for task in validator_modules])
|
||||
self.hacs.log.debug("Loaded %s validators for %s", len(self.validatiors), repository)
|
||||
|
||||
async def async_run_repository_checks(self, repository: HacsRepository) -> None:
|
||||
"""Run all validators for a repository."""
|
||||
@@ -55,21 +54,28 @@ class ValidationManager:
|
||||
|
||||
await self.async_load(repository)
|
||||
|
||||
await asyncio.gather(
|
||||
*[
|
||||
validator.execute_validation()
|
||||
for validator in self.validatiors or []
|
||||
if (
|
||||
validator.category == "common" or validator.category == repository.data.category
|
||||
)
|
||||
]
|
||||
is_pull_from_fork = (
|
||||
not os.getenv("INPUT_REPOSITORY")
|
||||
and os.getenv("GITHUB_REPOSITORY") != repository.data.full_name
|
||||
)
|
||||
|
||||
total = len(self.validatiors)
|
||||
failed = len([x for x in self.validatiors if x.failed])
|
||||
validators = [
|
||||
validator
|
||||
for validator in self.validators or []
|
||||
if (
|
||||
(not validator.categories or repository.data.category in validator.categories)
|
||||
and validator.slug not in os.getenv("INPUT_IGNORE", "").split(" ")
|
||||
and (not is_pull_from_fork or validator.allow_fork)
|
||||
)
|
||||
]
|
||||
|
||||
await asyncio.gather(*[validator.execute_validation() for validator in validators])
|
||||
|
||||
total = len(validators)
|
||||
failed = len([x for x in validators if x.failed])
|
||||
|
||||
if failed != 0:
|
||||
repository.logger.error("%s %s/%s checks failed", repository.string, failed, total)
|
||||
exit(1)
|
||||
else:
|
||||
repository.logger.debug("%s All (%s) checks passed", repository.string, total)
|
||||
repository.logger.info("%s All (%s) checks passed", repository.string, total)
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from ..repositories.base import HacsRepository
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from .base import ActionValidationBase, ValidationException
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..repositories.base import HacsRepository
|
||||
|
||||
|
||||
async def async_setup_validator(repository: HacsRepository) -> Validator:
|
||||
"""Set up this validator."""
|
||||
@@ -12,7 +16,10 @@ async def async_setup_validator(repository: HacsRepository) -> Validator:
|
||||
class Validator(ActionValidationBase):
|
||||
"""Validate the repository."""
|
||||
|
||||
async def async_validate(self):
|
||||
more_info = "https://hacs.xyz/docs/publish/include#check-repository"
|
||||
allow_fork = False
|
||||
|
||||
async def async_validate(self) -> None:
|
||||
"""Validate the repository."""
|
||||
if not self.repository.data.topics:
|
||||
raise ValidationException("The repository has no topics")
|
||||
raise ValidationException("The repository has no valid topics")
|
||||
3
deploy.sh
Executable file
3
deploy.sh
Executable file
@@ -0,0 +1,3 @@
|
||||
#!/bin/sh
|
||||
|
||||
ansible-playbook -bK -i .private/inventory -u ansible --key-file .private/id_ansible deploy.yaml
|
||||
18
deploy.yaml
Normal file
18
deploy.yaml
Normal file
@@ -0,0 +1,18 @@
|
||||
---
|
||||
# Ansible Playbook to deploy Home Assistant Configuration
|
||||
|
||||
- name: Home Assistant | Update Configuration
|
||||
hosts: hass
|
||||
tasks:
|
||||
- name: Home Assistant | Update Configuration
|
||||
ansible.builtin.git:
|
||||
repo: https://git.asymworks.com/jkrauss/home-assistant.git
|
||||
dest: /srv/hass/config
|
||||
notify: Home Assistant | Restart Home Assistant
|
||||
|
||||
handlers:
|
||||
- name: Home Assistant | Restart Home Assistant
|
||||
community.docker.docker_container:
|
||||
name: hass
|
||||
state: started
|
||||
restart: true
|
||||
@@ -0,0 +1,23 @@
|
||||
# Home Assistant Dashboards
|
||||
|
||||
# krauss-home:
|
||||
# mode: yaml
|
||||
# filename: lovelace/krauss-home.yaml
|
||||
# title: Krauss Home
|
||||
# icon: mdi:home
|
||||
# show_in_sidebar: true
|
||||
|
||||
# clock-ulm:
|
||||
# mode: yaml
|
||||
# filename: lovelace/clock-nightstand-jp-ulm.yaml
|
||||
# title: Nightstand Clock
|
||||
# icon: mdi:moon
|
||||
# show_in_sidebar: true
|
||||
|
||||
# lovelace-generated:
|
||||
# mode: yaml
|
||||
# filename: notexist.yaml
|
||||
# title: Generated
|
||||
# icon: mdi:tools
|
||||
# show_in_sidebar: false
|
||||
# require_admin: true
|
||||
|
||||
99
lovelace/krauss-home.yaml
Normal file
99
lovelace/krauss-home.yaml
Normal file
@@ -0,0 +1,99 @@
|
||||
# Default Dashboard
|
||||
|
||||
title: Krauss Home
|
||||
views:
|
||||
|
||||
# --------------------------------------------------
|
||||
# Overview Panel
|
||||
- id: 0
|
||||
icon: mdi:home
|
||||
title: Overview
|
||||
cards:
|
||||
# Early Morning Scenes (conditional)
|
||||
- type: conditional
|
||||
conditions:
|
||||
- entity: binary_sensor.before_dawn
|
||||
state: "on"
|
||||
card:
|
||||
type: entities
|
||||
title: Morning Scenes
|
||||
entities:
|
||||
- scene.dining_room_dim
|
||||
|
||||
# Evening Scenes (conditional)
|
||||
- type: conditional
|
||||
conditions:
|
||||
- entity: binary_sensor.evening
|
||||
state: "on"
|
||||
card:
|
||||
type: entities
|
||||
title: Evening Scenes
|
||||
entities:
|
||||
- scene.dining_room_full
|
||||
- scene.pergola_low
|
||||
- scene.pergola_full
|
||||
|
||||
# Nighttime Scenes (conditional)
|
||||
- type: conditional
|
||||
conditions:
|
||||
- entity: binary_sensor.night
|
||||
state: "on"
|
||||
card:
|
||||
type: entities
|
||||
title: Night Scenes
|
||||
entities:
|
||||
- scene.dining_room_off
|
||||
- scene.home_theater_off
|
||||
- scene.pergola_off
|
||||
|
||||
# House Status Glances
|
||||
- type: glance
|
||||
entities:
|
||||
- entity: sensor.house_presence_status
|
||||
name: Presence
|
||||
- entity: binary_sensor.all_doors
|
||||
name: Doors
|
||||
- entity: binary_sensor.all_windows
|
||||
name: Windows
|
||||
- entity: binary_sensor.garage_door_open
|
||||
name: Garage
|
||||
hold_action:
|
||||
action: call-service
|
||||
service: switch.turn_on
|
||||
data:
|
||||
entity_id: switch.garage_door_relay_1
|
||||
|
||||
# --------------------------------------------------
|
||||
# Climate Control Panel
|
||||
- path: climate
|
||||
icon: mdi:home-thermometer
|
||||
title: Climate Control
|
||||
cards:
|
||||
- type: thermostat
|
||||
entity: climate.thermostat
|
||||
|
||||
- type: entities
|
||||
entities:
|
||||
- entity: input_select.climate_mode
|
||||
- entity: automation.pause_hvac_when_doors_windows_open
|
||||
- entity: automation.restore_hvac_when_doors_windows_closed
|
||||
show_header_toggle: false
|
||||
title: HVAC Control
|
||||
|
||||
- type: entities
|
||||
entities:
|
||||
- entity: automation.narrow_hvac_range_during_day
|
||||
- entity: input_datetime.hvac_start_day
|
||||
- entity: input_number.temp_setpoint_day_high
|
||||
- entity: input_number.temp_setpoint_day_low
|
||||
show_header_toggle: false
|
||||
title: Daytime
|
||||
|
||||
- type: entities
|
||||
entities:
|
||||
- entity: automation.widen_hvac_range_at_night
|
||||
- entity: input_datetime.hvac_start_night
|
||||
- entity: input_number.temp_setpoint_night_high
|
||||
- entity: input_number.temp_setpoint_night_low
|
||||
show_header_toggle: false
|
||||
title: Nighttime
|
||||
@@ -0,0 +1,73 @@
|
||||
# Home Assistant Dashboard Resources (installed via HACS)
|
||||
|
||||
# Apex Charts Card
|
||||
- url: /hacsfiles/apexcharts-card/apexcharts-card.js
|
||||
type: module
|
||||
|
||||
# Auto Entities
|
||||
- url: /hacsfiles/lovelace-auto-entities/auto-entities.js
|
||||
type: module
|
||||
|
||||
# Bar Card
|
||||
- url: /hacsfiles/bar-card/bar-card.js
|
||||
type: module
|
||||
|
||||
# Button Card
|
||||
- url: /hacsfiles/button-card/button-card.js
|
||||
type: module
|
||||
|
||||
# Clock Weather Card
|
||||
- url: /hacsfiles/clock-weather-card/clock-weather-card.js
|
||||
type: module
|
||||
|
||||
# Frigate Card
|
||||
- url: /hacsfiles/frigate-hass-card/frigate-hass-card.js
|
||||
type: module
|
||||
|
||||
# Hourly Weather Card
|
||||
- url: /hacsfiles/lovelace-hourly-weather/hourly-weather.js
|
||||
type: module
|
||||
|
||||
# Layout Card
|
||||
- url: /hacsfiles/lovelace-layout-card/layout-card.js
|
||||
type: module
|
||||
|
||||
# Light Entity Card
|
||||
- url: /hacsfiles/light-entity-card/light-entity-card.js
|
||||
type: module
|
||||
|
||||
# Mini Graph Card
|
||||
- url: /hacsfiles/mini-graph-card/mini-graph-card-bundle.js
|
||||
type: module
|
||||
|
||||
# Mini Media Player
|
||||
- url: /hacsfiles/mini-media-player/mini-media-player-bundle.js
|
||||
type: module
|
||||
|
||||
# Mushroom Card
|
||||
- url: /hacsfiles/lovelace-mushroom/mushroom.js
|
||||
type: module
|
||||
|
||||
# My Cards
|
||||
- url: /hacsfiles/my-cards/my-cards.js
|
||||
type: module
|
||||
|
||||
# Simple Weather Card
|
||||
- url: /hacsfiles/simple-weather-card/simple-weather-card-bundle.js
|
||||
type: module
|
||||
|
||||
# State Switch
|
||||
- url: /hacsfiles/lovelace-state-switch/state-switch.js
|
||||
type: module
|
||||
|
||||
# Wall Panel
|
||||
- url: /hacsfiles/lovelace-wallpanel/wallpanel.js
|
||||
type: module
|
||||
|
||||
# Valetudo Map Card
|
||||
- url: /hacsfiles/lovelace-valetudo-map-card/valetudo-map-card.js
|
||||
type: module
|
||||
|
||||
# Vertical Stack in Card
|
||||
- url: /hacsfiles/vertical-stack-in-card/vertical-stack-in-card.js
|
||||
type: module
|
||||
|
||||
0
media/.gitkeep
Normal file
0
media/.gitkeep
Normal file
4
packages/alerts/README.md
Normal file
4
packages/alerts/README.md
Normal file
@@ -0,0 +1,4 @@
|
||||
# Alerts Package
|
||||
|
||||
This package contains configuration information for various uncategorized
|
||||
alerts for the home.
|
||||
130
packages/alerts/alerts.yaml
Normal file
130
packages/alerts/alerts.yaml
Normal file
@@ -0,0 +1,130 @@
|
||||
# Alerts Package
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Alert Automations
|
||||
automation:
|
||||
- alias: Notify if Leak Sensors Trigger
|
||||
mode: queued
|
||||
trigger:
|
||||
- platform: state
|
||||
entity_id: binary_sensor.dishwasher_leak_detected
|
||||
to: 'on'
|
||||
- platform: state
|
||||
entity_id: binary_sensor.washer_leak_detected
|
||||
to: 'on'
|
||||
action:
|
||||
- service: notify.everyone
|
||||
data:
|
||||
message: "{{ trigger.to_state.name }} Triggered"
|
||||
|
||||
- alias: Notify if Garage Fridge Temperature is High
|
||||
mode: queued
|
||||
trigger:
|
||||
- platform: numeric_state
|
||||
entity_id: sensor.garage_fridge_refrigerator_temperature
|
||||
above: 26.0
|
||||
- platform: numeric_state
|
||||
entity_id: sensor.garage_fridge_freezer_temperature
|
||||
above: 26.0
|
||||
action:
|
||||
- service: notify.everyone
|
||||
data:
|
||||
message: "{{ trigger.to_state.name }} is too high, check the power and doors."
|
||||
|
||||
- alias: Notify if Garage Fridge Sensor is Unavailable
|
||||
mode: queued
|
||||
trigger:
|
||||
- platform: state
|
||||
entity_id:
|
||||
- sensor.garage_fridge_refrigerator_temperature
|
||||
- sensor.garage_fridge_freezer_temperature
|
||||
to:
|
||||
- 'none'
|
||||
- 'unavailable'
|
||||
- 'unknown'
|
||||
for: '00:05:00'
|
||||
action:
|
||||
- service: notify.everyone
|
||||
data:
|
||||
message: "{{ trigger.to_state.name }} is unavilable, check the power."
|
||||
|
||||
- alias: Notify if Garage Door Left Open
|
||||
mode: queued
|
||||
trigger:
|
||||
- platform: numeric_state
|
||||
entity_id: sun.sun
|
||||
attribute: elevation
|
||||
below: -4.0
|
||||
- platform: state
|
||||
entity_id: binary_sensor.house_presence
|
||||
to: 'off'
|
||||
condition:
|
||||
- "{{ is_state('binary_sensor.garage_door', 'on') }}"
|
||||
action:
|
||||
- service: notify.everyone
|
||||
data:
|
||||
message: Garage Door is Open
|
||||
|
||||
- alias: Notify on Low Battery Level
|
||||
mode: queued
|
||||
trigger:
|
||||
- platform: numeric_state
|
||||
entity_id:
|
||||
# Door Locks
|
||||
- sensor.front_door_lock_battery
|
||||
# Door Sensors
|
||||
- sensor.front_door_battery
|
||||
- sensor.laundry_door_battery
|
||||
- sensor.living_room_door_battery
|
||||
# Window Sensors
|
||||
- sensor.bathroom_window_battery
|
||||
- sensor.bedroom_back_window_battery
|
||||
- sensor.bedroom_side_window_battery
|
||||
- sensor.dining_room_front_window_battery
|
||||
- sensor.dining_room_side_window_battery
|
||||
- sensor.guest_room_front_window_battery
|
||||
- sensor.guest_room_side_window_battery
|
||||
- sensor.kitchen_left_window_battery
|
||||
- sensor.kitchen_right_window_battery
|
||||
- sensor.laundry_window_battery
|
||||
- sensor.living_room_left_window_battery
|
||||
- sensor.living_room_right_window_battery
|
||||
- sensor.office_window_battery
|
||||
# Leak Sensors
|
||||
- sensor.dishwasher_leak_sensor_battery_level
|
||||
- sensor.washer_leak_sensor_battery_level
|
||||
# PIR Sensors
|
||||
- sensor.entry_sensor_battery_level
|
||||
- sensor.garage_sensor_battery_level
|
||||
- sensor.guest_room_sensor_battery_level
|
||||
- sensor.hallway_sensor_battery_level
|
||||
- sensor.office_sensor_battery_level
|
||||
# Temp Sensors
|
||||
- sensor.attic_sensor_battery
|
||||
- sensor.server_rack_sensor_battery
|
||||
- sensor.smc_sensor_battery
|
||||
- sensor.wine_fridge_sensor_battery
|
||||
# Nightstand Buttons
|
||||
- sensor.jen_nightstand_button_battery
|
||||
- sensor.jp_nightstand_button_battery
|
||||
# bhyve
|
||||
- sensor.patio_containers_battery_level
|
||||
- sensor.smart_hose_timer_battery_level
|
||||
# Other
|
||||
- sensor.thermostat_battery_level
|
||||
below: 30
|
||||
action:
|
||||
- alias: Send Low Battery Notification to ntfy
|
||||
action: shell_command.ntfy
|
||||
data:
|
||||
topic: home_assistant
|
||||
tags:
|
||||
- battery
|
||||
title: Low Battery Alert
|
||||
message: "Low battery on {{ trigger.to_state.name }}"
|
||||
- alias: Setup a Persistent Notification
|
||||
action: persistent_notification.create
|
||||
data:
|
||||
title: "Low Battery on {{ trigger.to_state.name }}"
|
||||
message: Battery level on {{ trigger.to_state.name }} is now {{ trigger.to_state.state }}
|
||||
notification_id: "LOW_BATTERY_{{ trigger.entity_id | replace('.', '_') }}"
|
||||
4
packages/climate/README.md
Normal file
4
packages/climate/README.md
Normal file
@@ -0,0 +1,4 @@
|
||||
# Climate Package
|
||||
|
||||
This package contains configuration information for the Z-wave smart thermostat
|
||||
system. The thermostat should be set up via the UI as `climate.thermostat`.
|
||||
252
packages/climate/climate.yaml
Normal file
252
packages/climate/climate.yaml
Normal file
@@ -0,0 +1,252 @@
|
||||
# Climate Package
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Climate State Helpers
|
||||
input_select:
|
||||
climate_mode:
|
||||
name: Climate Control Mode
|
||||
options:
|
||||
- 'Off'
|
||||
- Auto
|
||||
- Manual
|
||||
- Paused
|
||||
|
||||
input_number:
|
||||
temp_setpoint_day_high:
|
||||
name: Temperature Setpoint Day (High)
|
||||
initial: 74
|
||||
min: 60
|
||||
max: 90
|
||||
step: 1
|
||||
temp_setpoint_day_low:
|
||||
name: Temperature Setpoint Day (Low)
|
||||
initial: 70
|
||||
min: 60
|
||||
max: 90
|
||||
step: 1
|
||||
temp_setpoint_night_high:
|
||||
name: Temperature Setpoint Night (High)
|
||||
initial: 76
|
||||
min: 60
|
||||
max: 90
|
||||
step: 1
|
||||
temp_setpoint_night_low:
|
||||
name: Temperature Setpoint Night (Low)
|
||||
initial: 70
|
||||
min: 60
|
||||
max: 90
|
||||
step: 1
|
||||
temp_setpoint_away_high:
|
||||
name: Temperature Setpoint Away (High)
|
||||
initial: 80
|
||||
min: 60
|
||||
max: 90
|
||||
step: 1
|
||||
temp_setpoint_away_low:
|
||||
name: Temperature Setpoint Away (Low)
|
||||
initial: 66
|
||||
min: 60
|
||||
max: 90
|
||||
step: 1
|
||||
|
||||
input_datetime:
|
||||
hvac_start_day:
|
||||
name: HVAC Daytime Start
|
||||
initial: '10:00'
|
||||
has_date: false
|
||||
has_time: true
|
||||
hvac_start_night:
|
||||
name: HVAC Nighttime Start
|
||||
initial: '20:00'
|
||||
has_date: false
|
||||
has_time: true
|
||||
|
||||
input_text:
|
||||
hvac_last_mode:
|
||||
|
||||
template:
|
||||
binary_sensor:
|
||||
- name: HVAC Fan Running
|
||||
state: >
|
||||
{{ 'Running' in state_attr('climate.thermostat', 'fan_state') }}
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Climate Scripts
|
||||
script:
|
||||
hvac_pause:
|
||||
alias: Pause HVAC
|
||||
mode: restart
|
||||
sequence:
|
||||
# Save the current thermostat mode because it may not be captured properly
|
||||
# by `scene.create` (see https://github.com/home-assistant/core/issues/69925)
|
||||
- action: input_text.set_value
|
||||
target:
|
||||
entity_id: input_text.hvac_last_mode
|
||||
data:
|
||||
value: "{{ states('climate.thermostat') }}"
|
||||
|
||||
# Snapshot the current mode and thermostat status
|
||||
- action: scene.create
|
||||
data:
|
||||
scene_id: hvac_restore_state
|
||||
snapshot_entities:
|
||||
- climate.thermostat
|
||||
- input_select.climate_mode
|
||||
- input_text.hvac_last_mode
|
||||
|
||||
# Turn off the thermostat and set the mode to Paused
|
||||
- action: climate.set_hvac_mode
|
||||
target:
|
||||
entity_id: climate.thermostat
|
||||
data:
|
||||
hvac_mode: "off"
|
||||
|
||||
- service: input_select.select_option
|
||||
target:
|
||||
entity_id: input_select.climate_mode
|
||||
data:
|
||||
option: Paused
|
||||
|
||||
hvac_restore:
|
||||
alias: Resume HVAC
|
||||
mode: restart
|
||||
sequence:
|
||||
# Restore the snapshot
|
||||
- action: scene.turn_on
|
||||
target:
|
||||
entity_id: scene.hvac_restore_state
|
||||
|
||||
# Restore the thermostat mode
|
||||
- action: climate.set_hvac_mode
|
||||
target:
|
||||
entity_id: climate.thermostat
|
||||
data:
|
||||
hvac_mode: "{{ states('input_text.hvac_last_mode') }}"
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Climate Automations
|
||||
automation:
|
||||
|
||||
# Automations for Open Windows/Doors
|
||||
- alias: Pause HVAC when Doors/Windows Open
|
||||
mode: queued
|
||||
trigger:
|
||||
- platform: state
|
||||
entity_id: binary_sensor.all_doors
|
||||
to: 'on'
|
||||
for:
|
||||
minutes: 5
|
||||
- platform: state
|
||||
entity_id: binary_sensor.all_windows
|
||||
to: 'on'
|
||||
for:
|
||||
minutes: 5
|
||||
condition:
|
||||
- "{{ not is_state('input_select.climate_mode', 'Manual') }}"
|
||||
- "{{ not is_state('input_select.climate_mode', 'Paused') }}"
|
||||
- "{{ is_state('input_boolean.guest_mode', 'off') }}"
|
||||
action:
|
||||
# - service: scene.create
|
||||
# data:
|
||||
# scene_id: hvac_restore_state
|
||||
# snapshot_entities:
|
||||
# # See https://github.com/home-assistant/core/issues/69925
|
||||
# # - climate.thermostat
|
||||
# - input_select.climate_mode
|
||||
# - service: climate.set_hvac_mode
|
||||
# target:
|
||||
# entity_id: climate.thermostat
|
||||
# data:
|
||||
# hvac_mode: "off"
|
||||
# - service: input_select.select_option
|
||||
# target:
|
||||
# entity_id: input_select.climate_mode
|
||||
# data:
|
||||
# option: Paused
|
||||
- action: script.hvac_pause
|
||||
- action: notify.status
|
||||
data:
|
||||
message: "HVAC Paused ({{ states('sensor.open_windows_doors') }} open)"
|
||||
|
||||
- alias: Restore HVAC when Doors/Windows Closed
|
||||
mode: queued
|
||||
trigger:
|
||||
- platform: state
|
||||
entity_id: binary_sensor.all_doors
|
||||
from: 'on'
|
||||
to: 'off'
|
||||
for:
|
||||
minutes: 5
|
||||
- platform: state
|
||||
entity_id: binary_sensor.all_windows
|
||||
from: 'on'
|
||||
to: 'off'
|
||||
for:
|
||||
minutes: 5
|
||||
condition:
|
||||
- "{{ is_state('input_select.climate_mode', 'Paused') }}"
|
||||
- "{{ is_state('binary_sensor.all_doors', 'off') }}"
|
||||
- "{{ is_state('binary_sensor.all_windows', 'off') }}"
|
||||
action:
|
||||
# - service: scene.turn_on
|
||||
# target:
|
||||
# entity_id: scene.hvac_restore_state
|
||||
# # See https://github.com/home-assistant/core/issues/69925
|
||||
# # Assume thermostat is always heat_cool when not off
|
||||
# - service: climate.set_hvac_mode
|
||||
# target:
|
||||
# entity_id: climate.thermostat
|
||||
# data:
|
||||
# hvac_mode: "{{ 'off' if is_state('input_select.climate_mode', 'Off') else 'heat_cool' }}"
|
||||
- action: script.hvac_restore
|
||||
- action: notify.status
|
||||
data:
|
||||
message: HVAC Restored (Windows and Doors closed)
|
||||
|
||||
- alias: Widen HVAC Range at Night
|
||||
mode: queued
|
||||
trigger:
|
||||
- platform: time
|
||||
at: input_datetime.hvac_start_night
|
||||
condition:
|
||||
- "{{ not is_state('input_select.climate_mode', 'Manual') }}"
|
||||
- "{{ not is_state('input_select.house_presence_state', 'Extended Away') }}"
|
||||
action:
|
||||
- service: climate.set_temperature
|
||||
target:
|
||||
entity_id: climate.thermostat
|
||||
data:
|
||||
target_temp_high: "{{ states('input_number.temp_setpoint_night_high')|float }}"
|
||||
target_temp_low: "{{ states('input_number.temp_setpoint_night_low')|float }}"
|
||||
|
||||
- alias: Narrow HVAC Range during Day
|
||||
mode: queued
|
||||
trigger:
|
||||
- platform: time
|
||||
at: input_datetime.hvac_start_day
|
||||
condition:
|
||||
- "{{ not is_state('input_select.climate_mode', 'Manual') }}"
|
||||
- "{{ not is_state('input_select.house_presence_state', 'Extended Away') }}"
|
||||
action:
|
||||
- service: climate.set_temperature
|
||||
target:
|
||||
entity_id: climate.thermostat
|
||||
data:
|
||||
target_temp_high: "{{ states('input_number.temp_setpoint_day_high')|float }}"
|
||||
target_temp_low: "{{ states('input_number.temp_setpoint_day_low')|float }}"
|
||||
|
||||
- alias: Widen HVAC Range during Vacation
|
||||
mode: queued
|
||||
trigger:
|
||||
- platform: state
|
||||
entity_id: input_select.house_presence_state
|
||||
to: 'Extended Away'
|
||||
condition:
|
||||
- "{{ not is_state('input_select.climate_mode', 'Manual') }}"
|
||||
action:
|
||||
- service: climate.set_temperature
|
||||
target:
|
||||
entity_id: climate.thermostat
|
||||
data:
|
||||
target_temp_high: "{{ states('input_number.temp_setpoint_away_high')|float }}"
|
||||
target_temp_low: "{{ states('input_number.temp_setpoint_away_low')|float }}"
|
||||
@@ -1,3 +0,0 @@
|
||||
# Commute Package
|
||||
|
||||
This package contains configuration information for commute time tracking. Its primary job is to create auxiliary sensors for the Waze Drive Time sensors that have the correct device class for long-term statistic calculations.
|
||||
@@ -1,36 +0,0 @@
|
||||
# Commute Package
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Commute Time Helper Customization
|
||||
homeassistant:
|
||||
customize_glob:
|
||||
sensor.*_commute_*_meas:
|
||||
device_class: duration
|
||||
icon: mdi:car
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Commute Time Helpers
|
||||
template:
|
||||
- sensor:
|
||||
- name: "JP Commute Home Meas"
|
||||
unit_of_measurement: min
|
||||
state: "{{ states('sensor.jp_commute_home') }}"
|
||||
state_class: measurement
|
||||
|
||||
- sensor:
|
||||
- name: "JP Commute Work Meas"
|
||||
unit_of_measurement: min
|
||||
state: "{{ states('sensor.jp_commute_work') }}"
|
||||
state_class: measurement
|
||||
|
||||
- sensor:
|
||||
- name: "Jen Commute Home Meas"
|
||||
unit_of_measurement: min
|
||||
state: "{{ states('sensor.jen_commute_home') }}"
|
||||
state_class: measurement
|
||||
|
||||
- sensor:
|
||||
- name: "Jen Commute Work Meas"
|
||||
unit_of_measurement: min
|
||||
state: "{{ states('sensor.jen_commute_work') }}"
|
||||
state_class: measurement
|
||||
3
packages/datetime/README.md
Normal file
3
packages/datetime/README.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# Date/Time Package
|
||||
|
||||
This package contains helper sensors for dates and times.
|
||||
46
packages/datetime/datetime.yaml
Normal file
46
packages/datetime/datetime.yaml
Normal file
@@ -0,0 +1,46 @@
|
||||
# Date/Time Package
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Time of Day Sensors and Helpers
|
||||
sensor:
|
||||
- platform: time_date
|
||||
display_options:
|
||||
- 'time'
|
||||
- 'date'
|
||||
- 'date_time'
|
||||
|
||||
template:
|
||||
- sensor:
|
||||
- name: hour
|
||||
state: "{{ now().strftime('%I') }}"
|
||||
- name: minute
|
||||
state: "{{ now().strftime('%M') }}"
|
||||
|
||||
binary_sensor:
|
||||
- platform: tod
|
||||
name: Before Dawn
|
||||
after: "00:00"
|
||||
before: sunrise
|
||||
|
||||
- platform: tod
|
||||
name: Morning
|
||||
after: "04:00"
|
||||
before: "12:00"
|
||||
|
||||
- platform: tod
|
||||
name: Afternoon
|
||||
after: "12:00"
|
||||
before: "17:00"
|
||||
|
||||
- platform: tod
|
||||
name: Evening
|
||||
after: "17:00"
|
||||
before: "21:00"
|
||||
|
||||
- platform: tod
|
||||
name: Night
|
||||
after: "21:00"
|
||||
before: "04:00"
|
||||
|
||||
# - platform: workday
|
||||
# country: US
|
||||
5
packages/energy/README.md
Normal file
5
packages/energy/README.md
Normal file
@@ -0,0 +1,5 @@
|
||||
# Energy Package
|
||||
|
||||
This package contains configuration information for energy monitoring helpers.
|
||||
The Enlighten Envoy integration should be set up and configured for this
|
||||
package to work.
|
||||
614
packages/energy/energy.yaml
Normal file
614
packages/energy/energy.yaml
Normal file
@@ -0,0 +1,614 @@
|
||||
# Energy Package
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Energy Integration Sensors
|
||||
sensor:
|
||||
- name: Envoy Total Energy
|
||||
platform: integration
|
||||
source: sensor.envoy_202221032900_current_power_production
|
||||
unit_prefix: k
|
||||
unit_time: h
|
||||
|
||||
- name: Emporia Net Energy
|
||||
platform: integration
|
||||
source: sensor.emporia_d937d0_1min
|
||||
unit_prefix: k
|
||||
unit_time: h
|
||||
|
||||
- name: Total Energy From Grid
|
||||
platform: integration
|
||||
source: sensor.emporia_vue_net_power_from_grid
|
||||
unit_prefix: k
|
||||
unit_time: h
|
||||
|
||||
- name: Clean Energy From Grid
|
||||
platform: integration
|
||||
source: sensor.emporia_vue_clean_power_from_grid
|
||||
unit_prefix: k
|
||||
unit_time: h
|
||||
|
||||
- name: Total Energy To Grid
|
||||
platform: integration
|
||||
source: sensor.emporia_vue_net_power_to_grid
|
||||
unit_prefix: k
|
||||
unit_time: h
|
||||
|
||||
- name: Home Total Energy
|
||||
platform: integration
|
||||
source: sensor.home_power_consumption
|
||||
unit_prefix: k
|
||||
unit_time: h
|
||||
|
||||
- name: Flex Alert RSS
|
||||
platform: rest
|
||||
resource: 'http://content.caiso.com/awe/noticeflexRSS.xml'
|
||||
value_template: >
|
||||
{{
|
||||
(
|
||||
value_json['rss']['channel']['item']
|
||||
| selectattr('title', 'search', 'NOTICE')
|
||||
| list
|
||||
)[0]['title']
|
||||
}}
|
||||
json_attributes_path: '$.rss.channel'
|
||||
json_attributes:
|
||||
- item
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Average Panel Power Sensor
|
||||
- name: Envoy Inverter Average Power
|
||||
platform: min_max
|
||||
type: mean
|
||||
entity_ids:
|
||||
- sensor.envoy_202221032900_inverter_202147113780
|
||||
- sensor.envoy_202221032900_inverter_202147116830
|
||||
- sensor.envoy_202221032900_inverter_202147117162
|
||||
- sensor.envoy_202221032900_inverter_202147117631
|
||||
- sensor.envoy_202221032900_inverter_202147122858
|
||||
- sensor.envoy_202221032900_inverter_202147123517
|
||||
- sensor.envoy_202221032900_inverter_202147125027
|
||||
- sensor.envoy_202221032900_inverter_202147125590
|
||||
- sensor.envoy_202221032900_inverter_202147125734
|
||||
- sensor.envoy_202221032900_inverter_202147125902
|
||||
- sensor.envoy_202221032900_inverter_202147126079
|
||||
- sensor.envoy_202221032900_inverter_202147126357
|
||||
- sensor.envoy_202221032900_inverter_202147126997
|
||||
- sensor.envoy_202221032900_inverter_202147128369
|
||||
- sensor.envoy_202221032900_inverter_202147129445
|
||||
- sensor.envoy_202221032900_inverter_202147130152
|
||||
- sensor.envoy_202221032900_inverter_202147130290
|
||||
|
||||
- name: Envoy 202221032900 Inverter 202147113780 Average Power Share
|
||||
platform: statistics
|
||||
entity_id: sensor.envoy_202221032900_inverter_202147113780_power_share
|
||||
state_characteristic: mean
|
||||
max_age:
|
||||
hours: 24
|
||||
|
||||
- name: Envoy 202221032900 Inverter 202147116830 Average Power Share
|
||||
platform: statistics
|
||||
entity_id: sensor.envoy_202221032900_inverter_202147116830_power_share
|
||||
state_characteristic: mean
|
||||
max_age:
|
||||
hours: 24
|
||||
|
||||
- name: Envoy 202221032900 Inverter 202147117162 Average Power Share
|
||||
platform: statistics
|
||||
entity_id: sensor.envoy_202221032900_inverter_202147117162_power_share
|
||||
state_characteristic: mean
|
||||
max_age:
|
||||
hours: 24
|
||||
|
||||
- name: Envoy 202221032900 Inverter 202147117631 Average Power Share
|
||||
platform: statistics
|
||||
entity_id: sensor.envoy_202221032900_inverter_202147117631_power_share
|
||||
state_characteristic: mean
|
||||
max_age:
|
||||
hours: 24
|
||||
|
||||
- name: Envoy 202221032900 Inverter 202147122858 Average Power Share
|
||||
platform: statistics
|
||||
entity_id: sensor.envoy_202221032900_inverter_202147122858_power_share
|
||||
state_characteristic: mean
|
||||
max_age:
|
||||
hours: 24
|
||||
|
||||
- name: Envoy 202221032900 Inverter 202147123517 Average Power Share
|
||||
platform: statistics
|
||||
entity_id: sensor.envoy_202221032900_inverter_202147123517_power_share
|
||||
state_characteristic: mean
|
||||
max_age:
|
||||
hours: 24
|
||||
|
||||
- name: Envoy 202221032900 Inverter 202147125027 Average Power Share
|
||||
platform: statistics
|
||||
entity_id: sensor.envoy_202221032900_inverter_202147125027_power_share
|
||||
state_characteristic: mean
|
||||
max_age:
|
||||
hours: 24
|
||||
|
||||
- name: Envoy 202221032900 Inverter 202147125590 Average Power Share
|
||||
platform: statistics
|
||||
entity_id: sensor.envoy_202221032900_inverter_202147125590_power_share
|
||||
state_characteristic: mean
|
||||
max_age:
|
||||
hours: 24
|
||||
|
||||
- name: Envoy 202221032900 Inverter 202147125734 Average Power Share
|
||||
platform: statistics
|
||||
entity_id: sensor.envoy_202221032900_inverter_202147125734_power_share
|
||||
state_characteristic: mean
|
||||
max_age:
|
||||
hours: 24
|
||||
|
||||
- name: Envoy 202221032900 Inverter 202147125902 Average Power Share
|
||||
platform: statistics
|
||||
entity_id: sensor.envoy_202221032900_inverter_202147125902_power_share
|
||||
state_characteristic: mean
|
||||
max_age:
|
||||
hours: 24
|
||||
|
||||
- name: Envoy 202221032900 Inverter 202147126079 Average Power Share
|
||||
platform: statistics
|
||||
entity_id: sensor.envoy_202221032900_inverter_202147126079_power_share
|
||||
state_characteristic: mean
|
||||
max_age:
|
||||
hours: 24
|
||||
|
||||
- name: Envoy 202221032900 Inverter 202147126357 Average Power Share
|
||||
platform: statistics
|
||||
entity_id: sensor.envoy_202221032900_inverter_202147126357_power_share
|
||||
state_characteristic: mean
|
||||
max_age:
|
||||
hours: 24
|
||||
|
||||
- name: Envoy 202221032900 Inverter 202147126997 Average Power Share
|
||||
platform: statistics
|
||||
entity_id: sensor.envoy_202221032900_inverter_202147126997_power_share
|
||||
state_characteristic: mean
|
||||
max_age:
|
||||
hours: 24
|
||||
|
||||
- name: Envoy 202221032900 Inverter 202147128369 Average Power Share
|
||||
platform: statistics
|
||||
entity_id: sensor.envoy_202221032900_inverter_202147128369_power_share
|
||||
state_characteristic: mean
|
||||
max_age:
|
||||
hours: 24
|
||||
|
||||
- name: Envoy 202221032900 Inverter 202147129445 Average Power Share
|
||||
platform: statistics
|
||||
entity_id: sensor.envoy_202221032900_inverter_202147129445_power_share
|
||||
state_characteristic: mean
|
||||
max_age:
|
||||
hours: 24
|
||||
|
||||
- name: Envoy 202221032900 Inverter 202147130152 Average Power Share
|
||||
platform: statistics
|
||||
entity_id: sensor.envoy_202221032900_inverter_202147130152_power_share
|
||||
state_characteristic: mean
|
||||
max_age:
|
||||
hours: 24
|
||||
|
||||
- name: Envoy 202221032900 Inverter 202147130290 Average Power Share
|
||||
platform: statistics
|
||||
entity_id: sensor.envoy_202221032900_inverter_202147130290_power_share
|
||||
state_characteristic: mean
|
||||
max_age:
|
||||
hours: 24
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Panel/Inverter Power Share Sensors
|
||||
template:
|
||||
sensor:
|
||||
- name: Envoy 202221032900 Inverter 202147113780 Power Share
|
||||
unit_of_measurement: "%"
|
||||
availability: "{{ states('sensor.envoy_inverter_average_power')|float > 5 }}"
|
||||
state: >
|
||||
{% if states('sensor.envoy_inverter_average_power')|default(0)|float == 0 %}
|
||||
none
|
||||
{% else %}
|
||||
{{ (100 *
|
||||
states('sensor.envoy_202221032900_inverter_202147113780')|default(0)|float
|
||||
/ states('sensor.envoy_inverter_average_power')|float
|
||||
) | round(1)
|
||||
}}
|
||||
{% endif %}
|
||||
|
||||
- name: Envoy 202221032900 Inverter 202147116830 Power Share
|
||||
unit_of_measurement: "%"
|
||||
availability: "{{ states('sensor.envoy_inverter_average_power')|float > 5 }}"
|
||||
state: >
|
||||
{% if states('sensor.envoy_inverter_average_power')|default(0)|float == 0 %}
|
||||
none
|
||||
{% else %}
|
||||
{{ (100 *
|
||||
states('sensor.envoy_202221032900_inverter_202147116830')|default(0)|float
|
||||
/ states('sensor.envoy_inverter_average_power')|float
|
||||
) | round(1)
|
||||
}}
|
||||
{% endif %}
|
||||
|
||||
- name: Envoy 202221032900 Inverter 202147117162 Power Share
|
||||
unit_of_measurement: "%"
|
||||
availability: "{{ states('sensor.envoy_inverter_average_power')|float > 5 }}"
|
||||
state: >
|
||||
{% if states('sensor.envoy_inverter_average_power')|default(0)|float == 0 %}
|
||||
none
|
||||
{% else %}
|
||||
{{ (100 *
|
||||
states('sensor.envoy_202221032900_inverter_202147117162')|default(0)|float
|
||||
/ states('sensor.envoy_inverter_average_power')|float
|
||||
) | round(1)
|
||||
}}
|
||||
{% endif %}
|
||||
|
||||
- name: Envoy 202221032900 Inverter 202147117631 Power Share
|
||||
unit_of_measurement: "%"
|
||||
availability: "{{ states('sensor.envoy_inverter_average_power')|float > 5 }}"
|
||||
state: >
|
||||
{% if states('sensor.envoy_inverter_average_power')|default(0)|float == 0 %}
|
||||
none
|
||||
{% else %}
|
||||
{{ (100 *
|
||||
states('sensor.envoy_202221032900_inverter_202147117631')|default(0)|float
|
||||
/ states('sensor.envoy_inverter_average_power')|float
|
||||
) | round(1)
|
||||
}}
|
||||
{% endif %}
|
||||
|
||||
- name: Envoy 202221032900 Inverter 202147122858 Power Share
|
||||
unit_of_measurement: "%"
|
||||
availability: "{{ states('sensor.envoy_inverter_average_power')|float > 5 }}"
|
||||
state: >
|
||||
{% if states('sensor.envoy_inverter_average_power')|default(0)|float == 0 %}
|
||||
none
|
||||
{% else %}
|
||||
{{ (100 *
|
||||
states('sensor.envoy_202221032900_inverter_202147122858')|default(0)|float
|
||||
/ states('sensor.envoy_inverter_average_power')|float
|
||||
) | round(1)
|
||||
}}
|
||||
{% endif %}
|
||||
|
||||
- name: Envoy 202221032900 Inverter 202147123517 Power Share
|
||||
unit_of_measurement: "%"
|
||||
availability: "{{ states('sensor.envoy_inverter_average_power')|float > 5 }}"
|
||||
state: >
|
||||
{% if states('sensor.envoy_inverter_average_power')|default(0)|float == 0 %}
|
||||
none
|
||||
{% else %}
|
||||
{{ (100 *
|
||||
states('sensor.envoy_202221032900_inverter_202147123517')|default(0)|float
|
||||
/ states('sensor.envoy_inverter_average_power')|float
|
||||
) | round(1)
|
||||
}}
|
||||
{% endif %}
|
||||
|
||||
- name: Envoy 202221032900 Inverter 202147125027 Power Share
|
||||
unit_of_measurement: "%"
|
||||
availability: "{{ states('sensor.envoy_inverter_average_power')|float > 5 }}"
|
||||
state: >
|
||||
{% if states('sensor.envoy_inverter_average_power')|default(0)|float == 0 %}
|
||||
none
|
||||
{% else %}
|
||||
{{ (100 *
|
||||
states('sensor.envoy_202221032900_inverter_202147125027')|default(0)|float
|
||||
/ states('sensor.envoy_inverter_average_power')|float
|
||||
) | round(1)
|
||||
}}
|
||||
{% endif %}
|
||||
|
||||
- name: Envoy 202221032900 Inverter 202147125590 Power Share
|
||||
unit_of_measurement: "%"
|
||||
availability: "{{ states('sensor.envoy_inverter_average_power')|float > 5 }}"
|
||||
state: >
|
||||
{% if states('sensor.envoy_inverter_average_power')|default(0)|float == 0 %}
|
||||
none
|
||||
{% else %}
|
||||
{{ (100 *
|
||||
states('sensor.envoy_202221032900_inverter_202147125590')|default(0)|float
|
||||
/ states('sensor.envoy_inverter_average_power')|float
|
||||
) | round(1)
|
||||
}}
|
||||
{% endif %}
|
||||
|
||||
- name: Envoy 202221032900 Inverter 202147125734 Power Share
|
||||
unit_of_measurement: "%"
|
||||
availability: "{{ states('sensor.envoy_inverter_average_power')|float > 5 }}"
|
||||
state: >
|
||||
{% if states('sensor.envoy_inverter_average_power')|default(0)|float == 0 %}
|
||||
none
|
||||
{% else %}
|
||||
{{ (100 *
|
||||
states('sensor.envoy_202221032900_inverter_202147125734')|default(0)|float
|
||||
/ states('sensor.envoy_inverter_average_power')|float
|
||||
) | round(1)
|
||||
}}
|
||||
{% endif %}
|
||||
|
||||
- name: Envoy 202221032900 Inverter 202147125902 Power Share
|
||||
unit_of_measurement: "%"
|
||||
availability: "{{ states('sensor.envoy_inverter_average_power')|float > 5 }}"
|
||||
state: >
|
||||
{% if states('sensor.envoy_inverter_average_power')|default(0)|float == 0 %}
|
||||
none
|
||||
{% else %}
|
||||
{{ (100 *
|
||||
states('sensor.envoy_202221032900_inverter_202147125902')|default(0)|float
|
||||
/ states('sensor.envoy_inverter_average_power')|float
|
||||
) | round(1)
|
||||
}}
|
||||
{% endif %}
|
||||
|
||||
- name: Envoy 202221032900 Inverter 202147126079 Power Share
|
||||
unit_of_measurement: "%"
|
||||
availability: "{{ states('sensor.envoy_inverter_average_power')|float > 5 }}"
|
||||
state: >
|
||||
{% if states('sensor.envoy_inverter_average_power')|default(0)|float == 0 %}
|
||||
none
|
||||
{% else %}
|
||||
{{ (100 *
|
||||
states('sensor.envoy_202221032900_inverter_202147126079')|default(0)|float
|
||||
/ states('sensor.envoy_inverter_average_power')|float
|
||||
) | round(1)
|
||||
}}
|
||||
{% endif %}
|
||||
|
||||
- name: Envoy 202221032900 Inverter 202147126357 Power Share
|
||||
unit_of_measurement: "%"
|
||||
availability: "{{ states('sensor.envoy_inverter_average_power')|float > 5 }}"
|
||||
state: >
|
||||
{% if states('sensor.envoy_inverter_average_power')|default(0)|float == 0 %}
|
||||
none
|
||||
{% else %}
|
||||
{{ (100 *
|
||||
states('sensor.envoy_202221032900_inverter_202147126357')|default(0)|float
|
||||
/ states('sensor.envoy_inverter_average_power')|float
|
||||
) | round(1)
|
||||
}}
|
||||
{% endif %}
|
||||
|
||||
- name: Envoy 202221032900 Inverter 202147126997 Power Share
|
||||
unit_of_measurement: "%"
|
||||
availability: "{{ states('sensor.envoy_inverter_average_power')|float > 5 }}"
|
||||
state: >
|
||||
{% if states('sensor.envoy_inverter_average_power')|default(0)|float == 0 %}
|
||||
none
|
||||
{% else %}
|
||||
{{ (100 *
|
||||
states('sensor.envoy_202221032900_inverter_202147126997')|default(0)|float
|
||||
/ states('sensor.envoy_inverter_average_power')|float
|
||||
) | round(1)
|
||||
}}
|
||||
{% endif %}
|
||||
|
||||
- name: Envoy 202221032900 Inverter 202147128369 Power Share
|
||||
unit_of_measurement: "%"
|
||||
availability: "{{ states('sensor.envoy_inverter_average_power')|float > 5 }}"
|
||||
state: >
|
||||
{% if states('sensor.envoy_inverter_average_power')|default(0)|float == 0 %}
|
||||
none
|
||||
{% else %}
|
||||
{{ (100 *
|
||||
states('sensor.envoy_202221032900_inverter_202147128369')|default(0)|float
|
||||
/ states('sensor.envoy_inverter_average_power')|float
|
||||
) | round(1)
|
||||
}}
|
||||
{% endif %}
|
||||
|
||||
- name: Envoy 202221032900 Inverter 202147129445 Power Share
|
||||
unit_of_measurement: "%"
|
||||
availability: "{{ states('sensor.envoy_inverter_average_power')|float > 5 }}"
|
||||
state: >
|
||||
{% if states('sensor.envoy_inverter_average_power')|default(0)|float == 0 %}
|
||||
none
|
||||
{% else %}
|
||||
{{ (100 *
|
||||
states('sensor.envoy_202221032900_inverter_202147129445')|default(0)|float
|
||||
/ states('sensor.envoy_inverter_average_power')|float
|
||||
) | round(1)
|
||||
}}
|
||||
{% endif %}
|
||||
|
||||
- name: Envoy 202221032900 Inverter 202147130152 Power Share
|
||||
unit_of_measurement: "%"
|
||||
availability: "{{ states('sensor.envoy_inverter_average_power')|float > 5 }}"
|
||||
state: >
|
||||
{% if states('sensor.envoy_inverter_average_power')|default(0)|float == 0 %}
|
||||
none
|
||||
{% else %}
|
||||
{{ (100 *
|
||||
states('sensor.envoy_202221032900_inverter_202147130152')|default(0)|float
|
||||
/ states('sensor.envoy_inverter_average_power')|float
|
||||
) | round(1)
|
||||
}}
|
||||
{% endif %}
|
||||
|
||||
- name: Envoy 202221032900 Inverter 202147130290 Power Share
|
||||
unit_of_measurement: "%"
|
||||
availability: "{{ states('sensor.envoy_inverter_average_power')|float > 5 }}"
|
||||
state: >
|
||||
{% if states('sensor.envoy_inverter_average_power')|default(0)|float == 0 %}
|
||||
none
|
||||
{% else %}
|
||||
{{ (100 *
|
||||
states('sensor.envoy_202221032900_inverter_202147130290')|default(0)|float
|
||||
/ states('sensor.envoy_inverter_average_power')|float
|
||||
) | round(1)
|
||||
}}
|
||||
{% endif %}
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Emporia Vue To/From Grid Sensors
|
||||
- name: Emporia Vue Net Power From Grid
|
||||
device_class: power
|
||||
unit_of_measurement: W
|
||||
state: >
|
||||
{% set net_power = states('sensor.emporia_d937d0_1min')|float %}{{
|
||||
iif(net_power > 0, net_power, 0)
|
||||
}}
|
||||
|
||||
- name: Emporia Vue Clean Power From Grid
|
||||
device_class: power
|
||||
unit_of_measurement: W
|
||||
state: >
|
||||
{% set net_power = states('sensor.emporia_d937d0_1min')|float %}{{
|
||||
iif(net_power > 0, net_power, 0) * states('sensor.grid_fossil_fuel_percentage', 0)|float / 100.0
|
||||
}}
|
||||
|
||||
- name: Emporia Vue Net Power To Grid
|
||||
device_class: power
|
||||
unit_of_measurement: W
|
||||
state: >
|
||||
{% set net_power = states('sensor.emporia_d937d0_1min')|float %}{{
|
||||
iif(net_power < 0, -net_power, 0)
|
||||
}}
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Home Total Power Consumption Sensor
|
||||
- name: Home Power Consumption
|
||||
device_class: power
|
||||
unit_of_measurement: W
|
||||
state: >
|
||||
{{
|
||||
states('sensor.envoy_202221032900_current_power_production')|float
|
||||
+ states('sensor.emporia_d937d0_1min')|float
|
||||
}}
|
||||
|
||||
- name: Home Clean Power Consumption
|
||||
device_class: power
|
||||
unit_of_measurement: W
|
||||
state: >
|
||||
{{
|
||||
states('sensor.emporia_vue_clean_power_from_grid')|float
|
||||
+ states('sensor.envoy_202221032900_current_power_production')|float
|
||||
}}
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# TOU Schedules
|
||||
- name: TOU Season
|
||||
state: "{{ ['Winter', 'Summer'][now().month >= 6 and now().month < 10] }}"
|
||||
icon: mdi:weather-cloudy-clock
|
||||
|
||||
- name: TOU Period
|
||||
icon: mdi:calendar-clock
|
||||
state: >
|
||||
{% set is_weekend = now().strftime("%w") == 0 or now().strftime("%w") == 6 %}
|
||||
{% if states('sensor.tou_season') == "Summer" %}
|
||||
{% if now().hour >= 16 and now().hour < 21 %}
|
||||
{% if is_weekend %}
|
||||
{{ "Mid-Peak" }}
|
||||
{% else %}
|
||||
{{ "On-Peak" }}
|
||||
{% endif %}
|
||||
{% else %}
|
||||
{{ "Off-Peak" }}
|
||||
{% endif %}
|
||||
{% else %}
|
||||
{% if now().hour >= 16 and now().hour < 21 %}
|
||||
{{ "Mid-Peak" }}
|
||||
{% elif now().hour >= 21 or now().hour < 8 %}
|
||||
{{ "Off-Peak" }}
|
||||
{% else %}
|
||||
{{ "Super Off-Peak" }}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Utility Meters
|
||||
utility_meter:
|
||||
daily_energy:
|
||||
source: sensor.total_energy_from_grid
|
||||
name: Daily Energy Consumption
|
||||
cycle: daily
|
||||
tariffs:
|
||||
- Super Off-Peak
|
||||
- Off-Peak
|
||||
- Mid-Peak
|
||||
- On-Peak
|
||||
|
||||
daily_energy_total:
|
||||
source: sensor.total_energy_from_grid
|
||||
name: Daily Energy Consumption Total
|
||||
cycle: daily
|
||||
|
||||
daily_low_carbon:
|
||||
source: sensor.clean_energy_from_grid
|
||||
name: Daily Low Carbon Consumption
|
||||
cycle: daily
|
||||
|
||||
daily_generated:
|
||||
source: sensor.total_energy_to_grid
|
||||
name: Daily Energy Generation
|
||||
cycle: daily
|
||||
|
||||
daily_consumption:
|
||||
source: sensor.home_total_energy
|
||||
name: Daily Home Consumption
|
||||
cycle: daily
|
||||
|
||||
daily_solar:
|
||||
source: sensor.envoy_total_energy
|
||||
name: Daily Solar Production
|
||||
cycle: daily
|
||||
|
||||
monthly_consumed:
|
||||
source: sensor.total_energy_from_grid
|
||||
name: Monthly Energy Consumption
|
||||
cron: 0 0 10 * *
|
||||
tariffs:
|
||||
- Super Off-Peak
|
||||
- Off-Peak
|
||||
- Mid-Peak
|
||||
- On-Peak
|
||||
|
||||
monthly_generated:
|
||||
source: sensor.total_energy_to_grid
|
||||
name: Monthly Energy Generation
|
||||
cron: 0 0 10 * *
|
||||
|
||||
yearly_consumed:
|
||||
source: sensor.total_energy_from_grid
|
||||
name: Yearly Energy Consumption
|
||||
cron: 0 0 10 7 *
|
||||
tariffs:
|
||||
- Super Off-Peak
|
||||
- Off-Peak
|
||||
- Mid-Peak
|
||||
- On-Peak
|
||||
|
||||
yearly_generated:
|
||||
source: sensor.total_energy_to_grid
|
||||
name: Yearly Energy Generation
|
||||
cron: 0 0 10 7 *
|
||||
|
||||
|
||||
automation:
|
||||
# -----------------------------------------------------------------------------
|
||||
# Utility Meter Automations
|
||||
- alias: Set Utility Meter TOU Tariff
|
||||
trigger:
|
||||
- platform: state
|
||||
entity_id: sensor.tou_period
|
||||
- platform: homeassistant
|
||||
event: start
|
||||
action:
|
||||
- service: select.select_option
|
||||
target:
|
||||
entity_id: select.daily_energy
|
||||
data:
|
||||
option: "{{ states('sensor.tou_period') }}"
|
||||
|
||||
- service: select.select_option
|
||||
target:
|
||||
entity_id: select.monthly_consumed
|
||||
data:
|
||||
option: "{{ states('sensor.tou_period') }}"
|
||||
|
||||
- service: select.select_option
|
||||
target:
|
||||
entity_id: select.yearly_consumed
|
||||
data:
|
||||
option: "{{ states('sensor.tou_period') }}"
|
||||
|
||||
218
packages/energy/energy.yaml.j2
Normal file
218
packages/energy/energy.yaml.j2
Normal file
@@ -0,0 +1,218 @@
|
||||
# Energy Package
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Energy Integration Sensors
|
||||
sensor:
|
||||
- name: Envoy Total Energy
|
||||
platform: integration
|
||||
source: sensor.envoy_202221032900_current_power_production
|
||||
unit_prefix: k
|
||||
unit_time: h
|
||||
|
||||
- name: Emporia Net Energy
|
||||
platform: integration
|
||||
source: sensor.emporia_d937d0_1min
|
||||
unit_prefix: k
|
||||
unit_time: h
|
||||
|
||||
- name: Total Energy From Grid
|
||||
platform: integration
|
||||
source: sensor.emporia_vue_net_power_from_grid
|
||||
unit_prefix: k
|
||||
unit_time: h
|
||||
|
||||
- name: Total Energy To Grid
|
||||
platform: integration
|
||||
source: sensor.emporia_vue_net_power_to_grid
|
||||
unit_prefix: k
|
||||
unit_time: h
|
||||
|
||||
- name: Home Total Energy
|
||||
platform: integration
|
||||
source: sensor.home_power_consumption
|
||||
unit_prefix: k
|
||||
unit_time: h
|
||||
|
||||
- name: Flex Alert RSS
|
||||
platform: rest
|
||||
resource: 'http://content.caiso.com/awe/noticeflexRSS.xml'
|
||||
value_template: >
|
||||
{{
|
||||
(
|
||||
value_json['rss']['channel']['item']
|
||||
| selectattr('title', 'search', 'NOTICE')
|
||||
| list
|
||||
)[0]['title']
|
||||
}}
|
||||
json_attributes_path: '$.rss.channel'
|
||||
json_attributes:
|
||||
- item
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Average Panel Power Sensor
|
||||
- name: Envoy Inverter Average Power
|
||||
platform: min_max
|
||||
type: mean
|
||||
entity_ids:[% for sn in serial_numbers %]
|
||||
- sensor.envoy_202221032900_inverter_[[sn]]
|
||||
[%- endfor %]
|
||||
[%- for sn in serial_numbers %]
|
||||
|
||||
- name: Envoy 202221032900 Inverter [[sn]] Average Power Share
|
||||
platform: statistics
|
||||
entity_id: sensor.envoy_202221032900_inverter_[[sn]]_power_share
|
||||
state_characteristic: mean
|
||||
max_age:
|
||||
hours: 24
|
||||
[%- endfor %]
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Panel/Inverter Power Share Sensors
|
||||
template:
|
||||
sensor:[% for sn in serial_numbers %]
|
||||
- name: Envoy 202221032900 Inverter [[sn]] Power Share
|
||||
unit_of_measurement: "%"
|
||||
availability: "{{ states('sensor.envoy_inverter_average_power')|float > 5 }}"
|
||||
state: >
|
||||
{% if states('sensor.envoy_inverter_average_power')|default(0)|float == 0 %}
|
||||
none
|
||||
{% else %}
|
||||
{{ (100 *
|
||||
states('sensor.envoy_202221032900_inverter_[[sn]]')|default(0)|float
|
||||
/ states('sensor.envoy_inverter_average_power')|float
|
||||
) | round(1)
|
||||
}}
|
||||
{% endif %}
|
||||
[% endfor %]
|
||||
# -----------------------------------------------------------------------------
|
||||
# Emporia Vue To/From Grid Sensors
|
||||
- name: Emporia Vue Net Power From Grid
|
||||
device_class: power
|
||||
unit_of_measurement: W
|
||||
state: >
|
||||
{% set net_power = states('sensor.emporia_d937d0_1min')|float %}{{
|
||||
iif(net_power > 0, net_power, 0)
|
||||
}}
|
||||
|
||||
- name: Emporia Vue Net Power To Grid
|
||||
device_class: power
|
||||
unit_of_measurement: W
|
||||
state: >
|
||||
{% set net_power = states('sensor.emporia_d937d0_1min')|float %}{{
|
||||
iif(net_power < 0, -net_power, 0)
|
||||
}}
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Home Total Power Consumption Sensor
|
||||
- name: Home Power Consumption
|
||||
device_class: power
|
||||
unit_of_measurement: W
|
||||
state: >
|
||||
{{
|
||||
states('sensor.envoy_202221032900_current_power_production')|float
|
||||
+ states('sensor.emporia_d937d0_1min')|float
|
||||
}}
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# TOU Schedules
|
||||
- name: TOU Season
|
||||
state: "{{ ['Winter', 'Summer'][now().month >= 6 and now().month < 10] }}"
|
||||
icon: mdi:weather-cloudy-clock
|
||||
|
||||
- name: TOU Period
|
||||
icon: mdi:calendar-clock
|
||||
state: >
|
||||
{% set is_weekend = now().strftime("%w") == "0" or now().strftime("%w") == "6" %}
|
||||
{% if states('sensor.tou_season') == "Summer" %}
|
||||
{% if now().hour >= 16 and now().hour < 21 %}
|
||||
{% if is_weekend %}
|
||||
{{ "Mid-Peak" }}
|
||||
{% else %}
|
||||
{{ "On-Peak" }}
|
||||
{% endif %}
|
||||
{% else %}
|
||||
{{ "Off-Peak" }}
|
||||
{% endif %}
|
||||
{% else %}
|
||||
{% if now().hour >= 16 and now().hour < 21 %}
|
||||
{{ "Mid-Peak" }}
|
||||
{% elif now().hour >= 21 or now().hour < 8 %}
|
||||
{{ "Off-Peak" }}
|
||||
{% else %}
|
||||
{{ "Super Off-Peak" }}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Utility Meters
|
||||
utility_meter:
|
||||
daily_energy:
|
||||
source: sensor.total_energy_from_grid
|
||||
name: Daily Energy Consumption
|
||||
cycle: daily
|
||||
tariffs:
|
||||
- Super Off-Peak
|
||||
- Off-Peak
|
||||
- Mid-Peak
|
||||
- On-Peak
|
||||
|
||||
monthly_consumed:
|
||||
source: sensor.total_energy_from_grid
|
||||
name: Monthly Energy Consumption
|
||||
cron: 0 0 8 * *
|
||||
tariffs:
|
||||
- Super Off-Peak
|
||||
- Off-Peak
|
||||
- Mid-Peak
|
||||
- On-Peak
|
||||
|
||||
monthly_generated:
|
||||
source: sensor.total_energy_to_grid
|
||||
name: Monthly Energy Generation
|
||||
cron: 0 0 8 * *
|
||||
|
||||
yearly_consumed:
|
||||
source: sensor.total_energy_from_grid
|
||||
name: Yearly Energy Consumption
|
||||
cron: 0 0 15 7 *
|
||||
tariffs:
|
||||
- Super Off-Peak
|
||||
- Off-Peak
|
||||
- Mid-Peak
|
||||
- On-Peak
|
||||
|
||||
yearly_generated:
|
||||
source: sensor.total_energy_to_grid
|
||||
name: Yearly Energy Generation
|
||||
cron: 0 0 15 7 *
|
||||
|
||||
|
||||
automation:
|
||||
# -----------------------------------------------------------------------------
|
||||
# Utility Meter Automations
|
||||
- alias: Set Utility Meter TOU Tariff
|
||||
trigger:
|
||||
- platform: state
|
||||
entity_id: sensor.tou_period
|
||||
- platform: homeassistant
|
||||
event: start
|
||||
action:
|
||||
- service: select.select_option
|
||||
target:
|
||||
entity_id: select.daily_energy
|
||||
data:
|
||||
option: "{{ states('sensor.tou_period') }}"
|
||||
|
||||
- service: select.select_option
|
||||
target:
|
||||
entity_id: select.monthly_consumed
|
||||
data:
|
||||
option: "{{ states('sensor.tou_period') }}"
|
||||
|
||||
- service: select.select_option
|
||||
target:
|
||||
entity_id: select.yearly_consumed
|
||||
data:
|
||||
option: "{{ states('sensor.tou_period') }}"
|
||||
|
||||
|
||||
46
packages/energy/generate_yaml.py
Normal file
46
packages/energy/generate_yaml.py
Normal file
@@ -0,0 +1,46 @@
|
||||
#!/usr/bin/python3
|
||||
# ---
|
||||
# Script to generate the energy.yaml package file.
|
||||
|
||||
import jinja2 as j2
|
||||
|
||||
# Serial Numbers for Panel Inverters
|
||||
INVERTER_SNS = [
|
||||
202147113780,
|
||||
202147116830,
|
||||
202147117162,
|
||||
202147117631,
|
||||
202147122858,
|
||||
202147123517,
|
||||
202147125027,
|
||||
202147125590,
|
||||
202147125734,
|
||||
202147125902,
|
||||
202147126079,
|
||||
202147126357,
|
||||
202147126997,
|
||||
202147128369,
|
||||
202147129445,
|
||||
202147130152,
|
||||
202147130290,
|
||||
]
|
||||
|
||||
def main():
|
||||
"""Generate the YAML File."""
|
||||
env = j2.Environment(
|
||||
block_start_string='[%',
|
||||
block_end_string='%]',
|
||||
variable_start_string='[[',
|
||||
variable_end_string=']]',
|
||||
comment_start_string='[#',
|
||||
comment_end_string='#]',
|
||||
loader=j2.FileSystemLoader('.'),
|
||||
)
|
||||
|
||||
with open('energy.yaml', 'w') as f:
|
||||
tmpl = env.get_template('energy.yaml.j2')
|
||||
f.write(tmpl.render(serial_numbers=INVERTER_SNS))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
6
packages/garden/README.md
Normal file
6
packages/garden/README.md
Normal file
@@ -0,0 +1,6 @@
|
||||
# Garden Package
|
||||
|
||||
This package contains configuration information for garden sensors. The Plaid
|
||||
Spruce Sensors should be joined via ZHA or Z2M and available as
|
||||
`sensor.pepper_soil_sensor_mqtt_humidity` and
|
||||
`sensor.tomato_soil_sensor_mqtt_humidity`.
|
||||
26
packages/garden/garden.yaml
Normal file
26
packages/garden/garden.yaml
Normal file
@@ -0,0 +1,26 @@
|
||||
# Garden Package
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Garden Automations
|
||||
automation:
|
||||
|
||||
# Notify on Low Soil Moisture
|
||||
- alias: Notify on Low Soil Moisture
|
||||
mode: queued
|
||||
trigger:
|
||||
- platform: numeric_state
|
||||
entity_id: sensor.pepper_soil_sensor_mqtt_humidity
|
||||
below: 15
|
||||
- platform: numeric_state
|
||||
entity_id: sensor.tomato_soil_sensor_mqtt_humidity
|
||||
below: 15
|
||||
action:
|
||||
- service: persistent_notification.create
|
||||
data:
|
||||
title: Low Soil Mosture
|
||||
message: >
|
||||
Low Soil Moisture on {{ trigger.from_state.attributes.friendly_name }}
|
||||
- service: notify.general
|
||||
data:
|
||||
message: >
|
||||
Low Soil Moisture on {{ trigger.from_state.attributes.friendly_name }}
|
||||
3
packages/holiday/README.md
Normal file
3
packages/holiday/README.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# Holiday Package
|
||||
|
||||
This package contains automations related to the holidays, such as Christmas Light schedules. It expects the entities `swtich.christmas_lights_front` and `switch.christmas_lights_pergola` to exist.
|
||||
179
packages/holiday/holiday.yaml
Normal file
179
packages/holiday/holiday.yaml
Normal file
@@ -0,0 +1,179 @@
|
||||
# Holiday Package
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Holiday State Helpers
|
||||
input_boolean:
|
||||
holiday_override_lights_night:
|
||||
name: Always Turn On Holiday Lights at Night
|
||||
holiday_override_lights_morning:
|
||||
name: Always Turn On Holiday Lights in the Morning
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Holiday Sensors
|
||||
template:
|
||||
- binary_sensor:
|
||||
# Christmas Season is the day after Thanksgiving through New Years Eve
|
||||
- name: Is Christmas Season
|
||||
state: >
|
||||
{% set xmas_start = as_datetime('%.4d-11-%.2d'|format(now().year, 22 + (3 - as_datetime(now().year|string + '-11-01').weekday()) % 7 + 1))|as_local %}
|
||||
{% set xmas_end = as_datetime('%.4d-01-01'|format(now().year + 1))|as_local %}
|
||||
{% set today = today_at('00:00') %}
|
||||
{{ xmas_start <= today and today < xmas_end }}
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Holiday Groups
|
||||
group:
|
||||
holiday_outdoor_lights:
|
||||
name: Holiday Outdoor Lights
|
||||
entities:
|
||||
- switch.christmas_lights_roof
|
||||
- switch.christmas_lights_shrubs
|
||||
- switch.christmas_lights_front
|
||||
|
||||
holiday_arch_lights:
|
||||
name: Holiday Archway Lights
|
||||
entities:
|
||||
- light.arches_east
|
||||
- light.arches_west
|
||||
|
||||
holiday_indoor_lights:
|
||||
name: Holiday Indoor Lights
|
||||
entities:
|
||||
- switch.christmas_tree
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Holiday Automations
|
||||
automation:
|
||||
- alias: Holiday - Turn On Christmas Lights at Sunset
|
||||
trigger:
|
||||
- platform: sun
|
||||
event: sunset
|
||||
condition:
|
||||
- alias: Christmas Season or Light Override Enabled
|
||||
condition: or
|
||||
conditions:
|
||||
- "{{ is_state('binary_sensor.is_christmas_season', 'on') }}"
|
||||
- "{{ is_state('input_boolean.holiday_override_lights_night', 'on') }}"
|
||||
action:
|
||||
- service: switch.turn_on
|
||||
target:
|
||||
entity_id:
|
||||
- switch.christmas_lights_roof
|
||||
- switch.christmas_lights_shrubs
|
||||
- switch.christmas_lights_front
|
||||
|
||||
- alias: Holiday - Turn On Archway Lights at Sunset
|
||||
trigger:
|
||||
- platform: sun
|
||||
event: sunset
|
||||
condition:
|
||||
- alias: Christmas Season or Light Override Enabled
|
||||
condition: or
|
||||
conditions:
|
||||
- "{{ is_state('binary_sensor.is_christmas_season', 'on') }}"
|
||||
- "{{ is_state('input_boolean.holiday_override_lights_night', 'on') }}"
|
||||
action:
|
||||
- service: light.turn_on
|
||||
data:
|
||||
brightness_pct: 100
|
||||
target:
|
||||
entity_id:
|
||||
- light.arches_east
|
||||
- light.arches_west
|
||||
|
||||
- alias: Holiday - Turn On Christmas Lights in Morning
|
||||
trigger:
|
||||
- platform: time
|
||||
at: '04:30:00'
|
||||
condition:
|
||||
- alias: Christmas Season or Light Override Enabled
|
||||
condition: or
|
||||
conditions:
|
||||
- "{{ is_state('binary_sensor.is_christmas_season', 'on') }}"
|
||||
- "{{ is_state('input_boolean.holiday_override_lights_morning', 'on') }}"
|
||||
action:
|
||||
- service: switch.turn_on
|
||||
target:
|
||||
entity_id:
|
||||
- switch.christmas_lights_roof
|
||||
- switch.christmas_lights_shrubs
|
||||
- switch.christmas_lights_front
|
||||
|
||||
- alias: Holiday - Turn On Archway Lights in Morning
|
||||
trigger:
|
||||
- platform: time
|
||||
at: '04:30:00'
|
||||
condition:
|
||||
- alias: Christmas Season or Light Override Enabled
|
||||
condition: or
|
||||
conditions:
|
||||
- "{{ is_state('binary_sensor.is_christmas_season', 'on') }}"
|
||||
- "{{ is_state('input_boolean.holiday_override_lights_morning', 'on') }}"
|
||||
action:
|
||||
- service: light.turn_on
|
||||
data:
|
||||
brightness_pct: 100
|
||||
target:
|
||||
entity_id:
|
||||
- light.arches_east
|
||||
- light.arches_west
|
||||
|
||||
- alias: Holiday - Turn Off Christmas Lights at Midnight
|
||||
trigger:
|
||||
- platform: time
|
||||
at: '00:00:00'
|
||||
action:
|
||||
- service: switch.turn_off
|
||||
target:
|
||||
entity_id:
|
||||
- switch.christmas_lights_roof
|
||||
- switch.christmas_lights_shrubs
|
||||
- switch.christmas_lights_front
|
||||
|
||||
- alias: Holiday - Turn Off Archway Lights at Midnight
|
||||
trigger:
|
||||
- platform: time
|
||||
at: '00:00:00'
|
||||
action:
|
||||
- service: light.turn_off
|
||||
target:
|
||||
entity_id:
|
||||
- light.arches_east
|
||||
- light.arches_west
|
||||
|
||||
- alias: Holiday - Turn Off Christmas Lights at Sunrise
|
||||
trigger:
|
||||
- platform: sun
|
||||
event: sunrise
|
||||
offset: '-00:30:00'
|
||||
action:
|
||||
- service: switch.turn_off
|
||||
target:
|
||||
entity_id:
|
||||
- switch.christmas_lights_roof
|
||||
- switch.christmas_lights_shrubs
|
||||
- switch.christmas_lights_front
|
||||
|
||||
- alias: Holiday - Turn Off Archway Lights at Sunrise
|
||||
trigger:
|
||||
- platform: sun
|
||||
event: sunrise
|
||||
offset: '-00:30:00'
|
||||
action:
|
||||
- service: light.turn_off
|
||||
target:
|
||||
entity_id:
|
||||
- light.arches_east
|
||||
- light.arches_west
|
||||
|
||||
- alias: Holiday - Turn Off Overrides when Christmas Season Starts
|
||||
trigger:
|
||||
- platform: state
|
||||
entity_id: binary_sensor.is_christmas_season
|
||||
to: 'on'
|
||||
action:
|
||||
- service: input_boolean.turn_off
|
||||
target:
|
||||
entity_id:
|
||||
- input_boolean.holiday_override_lights_night
|
||||
- input_boolean.holiday_override_lights_morning
|
||||
@@ -1,4 +1,5 @@
|
||||
# Irrigation Package
|
||||
|
||||
This package contains configuration information for the Rachio irrigation system. The Rachio
|
||||
integration should be set up via the Configuration UI.
|
||||
integration should be set up via the Configuration UI. The Orbit B-hyve integration should
|
||||
be installed via HACS (https://github.com/sebr/bhyve-home-assistant).
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
# Irrigation Package
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Orbit B-hyve Faucet Valve (moved to UI config flow)
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Irrigation Sprinkler Groups
|
||||
switch:
|
||||
|
||||
3
packages/laundry/README.md
Normal file
3
packages/laundry/README.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# Laundry Package
|
||||
|
||||
This package contains automations for laundry monitoring and notification.
|
||||
181
packages/laundry/laundry.yaml
Normal file
181
packages/laundry/laundry.yaml
Normal file
@@ -0,0 +1,181 @@
|
||||
# Laundry Package
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Washer Final Pump-Out State Machine
|
||||
# OBSOLETE with new Miele W/D!!
|
||||
# input_select:
|
||||
# washer_complete_detect_state:
|
||||
# name: Washer Complete Detect State
|
||||
# options:
|
||||
# - Idle
|
||||
# - Stage 1 # Iw > 4A, <70s
|
||||
# - Stage 2 # Iw ~ [2A, 4A], 60s
|
||||
# - Stage 3 # dIw ~ [-1.0A, -0.4A], 45s
|
||||
# - Triggered # Iw < 0.2A, 60s
|
||||
|
||||
# input_number:
|
||||
# washer_complete_threshold_mid:
|
||||
# min: 0.0
|
||||
# max: 4.0
|
||||
# step: 0.1
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Laundry Automations
|
||||
automation:
|
||||
- alias: Notify when Washer Done
|
||||
mode: single
|
||||
trigger:
|
||||
- platform: state
|
||||
entity_id: sensor.washing_machine_program_phase
|
||||
to: finished
|
||||
- platform: state
|
||||
entity_id: sensor.washing_machine_program_phase
|
||||
to: anti_crease
|
||||
action:
|
||||
- service: notify.everyone
|
||||
data:
|
||||
message: Washer is Done
|
||||
|
||||
- alias: Notify when Dryer Done
|
||||
mode: single
|
||||
trigger:
|
||||
- platform: state
|
||||
entity_id: sensor.tumble_dryer_program_phase
|
||||
to: finished
|
||||
action:
|
||||
- service: notify.everyone
|
||||
data:
|
||||
message: Dryer is Done
|
||||
|
||||
# - alias: Notify when Dryer Done
|
||||
# mode: single
|
||||
# trigger:
|
||||
# - platform: numeric_state
|
||||
# entity_id: sensor.dryer_power_electric_consumption_a
|
||||
# below: 1.0
|
||||
# for:
|
||||
# minutes: 1
|
||||
# action:
|
||||
# - service: notify.everyone
|
||||
# data:
|
||||
# message: Dryer is done
|
||||
|
||||
# - alias: Washer Complete Detect - Stage 1
|
||||
# mode: single
|
||||
# trigger:
|
||||
# - platform: numeric_state
|
||||
# entity_id: sensor.washer_power_electric_consumption_a
|
||||
# above: 4
|
||||
# condition:
|
||||
# - "{{ is_state('input_select.washer_complete_detect_state', 'Idle') }}"
|
||||
# action:
|
||||
# - service: input_select.select_option
|
||||
# target:
|
||||
# entity_id: input_select.washer_complete_detect_state
|
||||
# data:
|
||||
# option: Stage 1
|
||||
|
||||
# - alias: Washer Complete Detect - Stage 2
|
||||
# mode: single
|
||||
# trigger:
|
||||
# - platform: numeric_state
|
||||
# entity_id: sensor.washer_power_electric_consumption_a
|
||||
# above: 2
|
||||
# below: 4
|
||||
# for:
|
||||
# seconds: 60
|
||||
# condition:
|
||||
# - "{{ is_state('input_select.washer_complete_detect_state', 'Stage 1') }}"
|
||||
# action:
|
||||
# - service: input_select.select_option
|
||||
# target:
|
||||
# entity_id: input_select.washer_complete_detect_state
|
||||
# data:
|
||||
# option: Stage 2
|
||||
# - service: input_number.set_value
|
||||
# target:
|
||||
# entity_id: input_number.washer_complete_threshold_mid
|
||||
# data:
|
||||
# value: "{{ states('sensor.washer_power_electric_consumption_a')|float }}"
|
||||
|
||||
# - alias: Washer Complete Detect - Stage 3
|
||||
# mode: single
|
||||
# trigger:
|
||||
# - platform: numeric_state
|
||||
# entity_id: sensor.washer_power_electric_consumption_a
|
||||
# value_template: "{{
|
||||
# states('sensor.washer_power_electric_consumption_a')|float
|
||||
# - states('input_number.washer_complete_threshold_mid')|float
|
||||
# }}"
|
||||
# above: -1.0
|
||||
# below: -0.4
|
||||
# for:
|
||||
# seconds: 45
|
||||
# condition:
|
||||
# - "{{ is_state('input_select.washer_complete_detect_state', 'Stage 2') }}"
|
||||
# action:
|
||||
# - service: input_select.select_option
|
||||
# target:
|
||||
# entity_id: input_select.washer_complete_detect_state
|
||||
# data:
|
||||
# option: Stage 3
|
||||
|
||||
# - alias: Washer Complete Detect - Trigger
|
||||
# mode: single
|
||||
# trigger:
|
||||
# - platform: numeric_state
|
||||
# entity_id: sensor.washer_power_electric_consumption_a
|
||||
# below: 0.2
|
||||
# for:
|
||||
# seconds: 60
|
||||
# condition:
|
||||
# - "{{ is_state('input_select.washer_complete_detect_state', 'Stage 3') }}"
|
||||
# action:
|
||||
# - service: input_select.select_option
|
||||
# target:
|
||||
# entity_id: input_select.washer_complete_detect_state
|
||||
# data:
|
||||
# option: Triggered
|
||||
|
||||
# - alias: Washer Complete Detect - Timeout
|
||||
# mode: single
|
||||
# trigger:
|
||||
# - platform: state
|
||||
# entity_id: input_select.washer_complete_detect_state
|
||||
# to: Stage 1
|
||||
# for:
|
||||
# seconds: 120
|
||||
# - platform: state
|
||||
# entity_id: input_select.washer_complete_detect_state
|
||||
# to: Stage 2
|
||||
# for:
|
||||
# seconds: 240
|
||||
# - platform: state
|
||||
# entity_id: input_select.washer_complete_detect_state
|
||||
# to: Stage 3
|
||||
# for:
|
||||
# seconds: 180
|
||||
# - platform: state
|
||||
# entity_id: input_select.washer_complete_detect_state
|
||||
# to: Triggered
|
||||
# for:
|
||||
# seconds: 60
|
||||
# action:
|
||||
# - service: input_select.select_option
|
||||
# target:
|
||||
# entity_id: input_select.washer_complete_detect_state
|
||||
# data:
|
||||
# option: Idle
|
||||
|
||||
# - alias: Notify when Washer Done
|
||||
# mode: single
|
||||
# trigger:
|
||||
# - platform: state
|
||||
# entity_id: input_select.washer_complete_detect_state
|
||||
# to: Triggered
|
||||
# action:
|
||||
# # The trigger occurs about 2 minutes before the lid unlocks
|
||||
# - delay: 120
|
||||
# - service: notify.everyone
|
||||
# data:
|
||||
# message: Washer is Done
|
||||
@@ -113,18 +113,54 @@ scene:
|
||||
entities:
|
||||
light.living_room_light:
|
||||
state: "on"
|
||||
brightness: 100
|
||||
brightness: 50
|
||||
|
||||
- name: Home Theater Dim
|
||||
entities:
|
||||
light.living_room_light:
|
||||
state: "on"
|
||||
brightness: 50
|
||||
brightness: 20
|
||||
|
||||
- name: Home Theater Off
|
||||
entities:
|
||||
light.living_room_light: "off"
|
||||
|
||||
# Dining Room Scenes
|
||||
- name: Dining Room Dim
|
||||
entities:
|
||||
light.dining_room_light:
|
||||
state: "on"
|
||||
brightness: 90
|
||||
|
||||
- name: Dining Room Full
|
||||
entities:
|
||||
light.dining_room_light:
|
||||
state: "on"
|
||||
brightness: 255
|
||||
|
||||
- name: Dining Room Off
|
||||
entities:
|
||||
light.dining_room_light:
|
||||
state: "off"
|
||||
|
||||
# Outdoor/Patio Scenes
|
||||
- name: Pergola Low
|
||||
entities:
|
||||
light.pergola_lights:
|
||||
state: "on"
|
||||
brightness: 128
|
||||
|
||||
- name: Pergola Full
|
||||
entities:
|
||||
light.pergola_lights:
|
||||
state: "on"
|
||||
brightness: 255
|
||||
|
||||
- name: Pergola Off
|
||||
entities:
|
||||
light.pergola_lights:
|
||||
state: "off"
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Lighting Scripts
|
||||
script:
|
||||
@@ -149,14 +185,14 @@ script:
|
||||
target:
|
||||
entity_id: scene.home_theater_dim
|
||||
data:
|
||||
transition: 3
|
||||
transition: 6
|
||||
- conditions: "{{ is_state('input_select.ht_lighting_mode', 'Theater') }}"
|
||||
sequence:
|
||||
- service: scene.turn_on
|
||||
target:
|
||||
entity_id: scene.home_theater_off
|
||||
data:
|
||||
transition: 3
|
||||
transition: 6
|
||||
|
||||
# Paused
|
||||
- conditions: "{{ is_state('input_select.ht_player_state', 'Paused') }}"
|
||||
@@ -168,14 +204,14 @@ script:
|
||||
target:
|
||||
entity_id: scene.home_theater_dim
|
||||
data:
|
||||
transition: 3
|
||||
transition: 6
|
||||
- conditions: "{{ is_state('input_select.ht_lighting_mode', 'Theater') }}"
|
||||
sequence:
|
||||
- service: scene.turn_on
|
||||
target:
|
||||
entity_id: scene.home_theater_dim
|
||||
data:
|
||||
transition: 3
|
||||
transition: 6
|
||||
|
||||
# Stopped
|
||||
- conditions: "{{ is_state('input_select.ht_player_state', 'Stopped') }}"
|
||||
@@ -187,7 +223,7 @@ script:
|
||||
target:
|
||||
entity_id: scene.home_theater_low
|
||||
data:
|
||||
transition: 3
|
||||
transition: 6
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Lighting Automations
|
||||
@@ -214,11 +250,11 @@ automation:
|
||||
- alias: Jen Nightstand Lamp
|
||||
mode: single
|
||||
trigger:
|
||||
- device_id: 98d9a86c7838fdbfcbdaeceef9a98f36
|
||||
domain: zha
|
||||
platform: device
|
||||
type: remote_button_short_press
|
||||
subtype: remote_button_short_press
|
||||
- trigger: device
|
||||
domain: mqtt
|
||||
device_id: fc6ade603bf6978d6a357f19aee52aea
|
||||
type: action
|
||||
subtype: single
|
||||
condition: []
|
||||
action:
|
||||
- type: toggle
|
||||
@@ -229,11 +265,11 @@ automation:
|
||||
- alias: J.P. Nightstand Lamp
|
||||
mode: single
|
||||
trigger:
|
||||
- device_id: f223ed24fe135bfd6bb7006dddb6b9f8
|
||||
domain: zha
|
||||
platform: device
|
||||
type: remote_button_short_press
|
||||
subtype: remote_button_short_press
|
||||
- trigger: device
|
||||
domain: mqtt
|
||||
device_id: d7247f6ae03db605aad8a5ff7de60331
|
||||
type: action
|
||||
subtype: single
|
||||
condition: []
|
||||
action:
|
||||
- type: toggle
|
||||
@@ -257,26 +293,53 @@ automation:
|
||||
entity_id: scene.jen_nightstand_high
|
||||
|
||||
# Automations for Presence Changes
|
||||
- alias: Turn On Porch Light after Chorale
|
||||
- alias: Turn On Porch Light when Arriving Home at Night
|
||||
trigger:
|
||||
- platform: zone
|
||||
entity_id: person.jpk
|
||||
zone: zone.chorale_performance
|
||||
event: leave
|
||||
- platform: zone
|
||||
entity_id: person.jpk
|
||||
zone: zone.chorale_rehearsal
|
||||
event: leave
|
||||
- platform: zone
|
||||
entity_id: person.jpk
|
||||
zone: zone.chorale_rehearsal_alt
|
||||
event: leave
|
||||
condition:
|
||||
- condition: state
|
||||
alias: "Sun down"
|
||||
entity_id: sun.sun
|
||||
state: "below_horizon"
|
||||
- platform: numeric_state
|
||||
entity_id: sensor.jen_nearest_distance
|
||||
below: 6500
|
||||
- platform: numeric_state
|
||||
entity_id: sensor.jp_nearest_distance
|
||||
below: 6500
|
||||
condition: "{{ state_attr('sun.sun', 'elevation') < 5 }}"
|
||||
mode: queued
|
||||
action:
|
||||
- service: light.turn_on
|
||||
entity_id: light.porch_light
|
||||
|
||||
# Automations for Dining Room Switch
|
||||
- alias: Dining Room Dim on Double Up Press
|
||||
mode: single
|
||||
trigger:
|
||||
# - platform: state
|
||||
# entity_id: light.dining_room_light_action
|
||||
# to: 'up_double'
|
||||
- device_id: cab3202d50d68353c9d8a7648c93052f
|
||||
domain: zha
|
||||
platform: device
|
||||
type: remote_button_double_press
|
||||
subtype: Up
|
||||
action:
|
||||
- service: scene.turn_on
|
||||
target:
|
||||
entity_id: scene.dining_room_dim
|
||||
data:
|
||||
transition: 6
|
||||
|
||||
- alias: Dining Room Full on Triple Up Press
|
||||
mode: single
|
||||
trigger:
|
||||
# - platform: state
|
||||
# entity_id: light.dining_room_light_action
|
||||
# to: 'up_double'
|
||||
- device_id: cab3202d50d68353c9d8a7648c93052f
|
||||
domain: zha
|
||||
platform: device
|
||||
type: remote_button_triple_press
|
||||
subtype: Up
|
||||
action:
|
||||
- service: scene.turn_on
|
||||
target:
|
||||
entity_id: scene.dining_room_full
|
||||
data:
|
||||
transition: 6
|
||||
|
||||
@@ -15,6 +15,15 @@ input_select:
|
||||
- Unknown
|
||||
initial: Idle
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Media Player Configuration Switches
|
||||
input_boolean:
|
||||
ht_adjust_volume_hvac:
|
||||
name: Home Theater Adjust Volume for HVAC
|
||||
|
||||
ht_volume_adjusted:
|
||||
name: Home Theater Volume is Adjusted
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Media Player Scripts
|
||||
script:
|
||||
@@ -36,6 +45,91 @@ script:
|
||||
data:
|
||||
option: Idle
|
||||
|
||||
# Shield TV Input
|
||||
#
|
||||
# Very annoying since apps don't necessarily tell Android if they are playing
|
||||
# (looking at you, Jellyfin) and we need two integrations to even get the full
|
||||
# story for the Android TV base status.
|
||||
#
|
||||
# ATV (app ID) | Cast | JF | State
|
||||
# ----------------------------|---------|---------|--------------------
|
||||
# off (XX) | XX | XX | Idle (system off)
|
||||
# on (com.spocky.projengmenu) | XX | XX | Idle (Projectivity Launcher)
|
||||
# on (org.jellyfin.androidtv) | XX | idle | Stopped (Jellyfin Home)
|
||||
# on (org.jellyfin.androidtv) | XX | playing | Playing (Jellyfin)
|
||||
# on (org.jellyfin.androidtv) | XX | paused | Paused (Jellyfin)
|
||||
# on (others) | idle | XX | Stopped (App Home)
|
||||
# on (others) | playing | XX | Playing (App)
|
||||
# on (others) | paused | XX | Paused (App)
|
||||
#
|
||||
- conditions: "{{ state_attr('media_player.living_room_receiver', 'source') == 'SHIELD' }}"
|
||||
sequence:
|
||||
- choose:
|
||||
- conditions:
|
||||
- condition: or
|
||||
conditions:
|
||||
- condition: template
|
||||
value_template: "{{ is_state('media_player.shield_tv_living_room_atv', 'off') }}"
|
||||
- condition: template
|
||||
value_template: "{{ state_attr('media_player.shield_tv_living_room_atv', 'app_id') == 'com.spocky.projengmenu' }}"
|
||||
sequence:
|
||||
- service: input_select.select_option
|
||||
target:
|
||||
entity_id: input_select.ht_player_state
|
||||
data:
|
||||
option: Idle
|
||||
|
||||
- conditions: "{{ state_attr('media_player.shield_tv_living_room_atv', 'app_id') == 'org.jellyfin.androidtv' }}"
|
||||
sequence:
|
||||
- choose:
|
||||
- conditions: "{{ is_state('media_player.shield_tv_living_room_jellyfin', 'playing') }}"
|
||||
sequence:
|
||||
- service: input_select.select_option
|
||||
target:
|
||||
entity_id: input_select.ht_player_state
|
||||
data:
|
||||
option: Playing
|
||||
|
||||
- conditions: "{{ is_state('media_player.shield_tv_living_room_jellyfin', 'paused') }}"
|
||||
sequence:
|
||||
- service: input_select.select_option
|
||||
target:
|
||||
entity_id: input_select.ht_player_state
|
||||
data:
|
||||
option: Paused
|
||||
|
||||
- conditions: "{{ is_state('media_player.shield_tv_living_room_jellyfin', 'idle') }}"
|
||||
sequence:
|
||||
- service: input_select.select_option
|
||||
target:
|
||||
entity_id: input_select.ht_player_state
|
||||
data:
|
||||
option: Stopped
|
||||
|
||||
- conditions: "{{ is_state('media_player.shield_tv_living_room_cast', 'playing') }}"
|
||||
sequence:
|
||||
- service: input_select.select_option
|
||||
target:
|
||||
entity_id: input_select.ht_player_state
|
||||
data:
|
||||
option: Playing
|
||||
|
||||
- conditions: "{{ is_state('media_player.shield_tv_living_room_cast', 'paused') }}"
|
||||
sequence:
|
||||
- service: input_select.select_option
|
||||
target:
|
||||
entity_id: input_select.ht_player_state
|
||||
data:
|
||||
option: Paused
|
||||
|
||||
- conditions: "{{ is_state('media_player.shield_tv_living_room_cast', 'idle') }}"
|
||||
sequence:
|
||||
- service: input_select.select_option
|
||||
target:
|
||||
entity_id: input_select.ht_player_state
|
||||
data:
|
||||
option: Stopped
|
||||
|
||||
# Roku Input
|
||||
- conditions: "{{ state_attr('media_player.living_room_receiver', 'source') == 'Roku Ultra' }}"
|
||||
sequence:
|
||||
@@ -111,5 +205,62 @@ automation:
|
||||
entity_id: media_player.living_room_receiver
|
||||
- platform: state
|
||||
entity_id: media_player.living_room_roku
|
||||
- platform: state
|
||||
entity_id: media_player.shield_tv_living_room_atv
|
||||
- platform: state
|
||||
entity_id: media_player.shield_tv_living_room_cast
|
||||
- platform: state
|
||||
entity_id: media_player.shield_tv_living_room_jellyfin
|
||||
action:
|
||||
- service: script.ht_player_state_update
|
||||
|
||||
- alias: Turn Up Volume when HVAC Fan Starts
|
||||
trigger:
|
||||
- platform: state
|
||||
entity_id: binary_sensor.hvac_fan_running
|
||||
to: "on"
|
||||
condition:
|
||||
- "{{ is_state('input_boolean.ht_adjust_volume_hvac', 'on') }}"
|
||||
- "{{ is_state('input_boolean.ht_volume_adjusted', 'off') }}"
|
||||
- "{{ is_state('media_player.living_room_receiver', 'on') }}"
|
||||
- "{{ is_state_attr('media_player.living_room_receiver', 'is_volume_muted', false) }}"
|
||||
action:
|
||||
- repeat:
|
||||
count: 6
|
||||
sequence:
|
||||
- delay:
|
||||
milliseconds: 250
|
||||
- service: media_player.volume_up
|
||||
target:
|
||||
entity_id: media_player.living_room_receiver
|
||||
|
||||
- service: input_boolean.turn_on
|
||||
target:
|
||||
entity_id: input_boolean.ht_volume_adjusted
|
||||
|
||||
- alias: Turn Down Volume when HVAC Fan Stops
|
||||
trigger:
|
||||
- platform: state
|
||||
entity_id: binary_sensor.hvac_fan_running
|
||||
from: "on"
|
||||
# The fan_state attribute turns off before the fan actually stops
|
||||
for:
|
||||
seconds: 45
|
||||
condition:
|
||||
- "{{ is_state('input_boolean.ht_adjust_volume_hvac', 'on') }}"
|
||||
- "{{ is_state('input_boolean.ht_volume_adjusted', 'on') }}"
|
||||
- "{{ is_state('media_player.living_room_receiver', 'on') }}"
|
||||
- "{{ is_state_attr('media_player.living_room_receiver', 'is_volume_muted', false) }}"
|
||||
action:
|
||||
- repeat:
|
||||
count: 6
|
||||
sequence:
|
||||
- delay:
|
||||
milliseconds: 250
|
||||
- service: media_player.volume_down
|
||||
target:
|
||||
entity_id: media_player.living_room_receiver
|
||||
|
||||
- service: input_boolean.turn_off
|
||||
target:
|
||||
entity_id: input_boolean.ht_volume_adjusted
|
||||
|
||||
@@ -1,16 +1,68 @@
|
||||
# Notification Package
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Notification Platforms
|
||||
telegram_bot:
|
||||
- platform: broadcast
|
||||
api_key: !secret telegram_api_key
|
||||
allowed_chat_ids:
|
||||
- !secret telegram_chat_id
|
||||
# Main Notification Sinks
|
||||
notify:
|
||||
- name: iphone_jp
|
||||
platform: group
|
||||
services:
|
||||
- action: mobile_app_j_p_s_iphone
|
||||
data:
|
||||
title: Home Assistant
|
||||
|
||||
- name: iphone_jen
|
||||
platform: group
|
||||
services:
|
||||
- action: mobile_app_jennifer_s_iphone_2
|
||||
data:
|
||||
title: Home Assistant
|
||||
|
||||
# `notify.security` used for high-priority security reporting including
|
||||
# arm/disarm and alarm events. Also used for all camera events and the
|
||||
# doorbell function.
|
||||
- name: security
|
||||
platform: group
|
||||
services:
|
||||
- action: persistent_notification
|
||||
- action: iphone_jp
|
||||
data:
|
||||
title: Home Security
|
||||
data:
|
||||
push:
|
||||
interruption-level: time-sensitive
|
||||
- action: iphone_jen
|
||||
data:
|
||||
title: Home Security
|
||||
data:
|
||||
push:
|
||||
interruption-level: time-sensitive
|
||||
|
||||
# `notify.status` used for simple status reporting (e.g. washer done)
|
||||
# Keeps these notifications separated from security alerts
|
||||
- name: status
|
||||
platform: group
|
||||
services:
|
||||
- action: iphone_jp
|
||||
data:
|
||||
data:
|
||||
group: 'status-notification-group'
|
||||
- action: iphone_jen
|
||||
data:
|
||||
data:
|
||||
group: 'status-notification-group'
|
||||
|
||||
- name: general
|
||||
platform: group
|
||||
services:
|
||||
- action: iphone_jp
|
||||
|
||||
- name: everyone
|
||||
platform: group
|
||||
services:
|
||||
- action: iphone_jp
|
||||
- action: iphone_jen
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Main Notification Sink
|
||||
notify:
|
||||
- name: telegram_home
|
||||
platform: telegram
|
||||
chat_id: !secret telegram_chat_id
|
||||
# Shell Command for ntfy
|
||||
shell_command:
|
||||
ntfy: !secret ntfy_command
|
||||
|
||||
@@ -14,59 +14,27 @@ homeassistant:
|
||||
sensor.jp_presence_status:
|
||||
entity_picture: https://gravatar.com/avatar/e78e623948f3675cf1c51544f9bec928
|
||||
|
||||
device_tracker.jp_p3a_gps:
|
||||
friendly_name: J.P. Pixel 3a
|
||||
icon: mdi:map-marker
|
||||
device_tracker.jp_p3a_ip:
|
||||
friendly_name: J.P. Pixel 3a
|
||||
icon: mdi:wifi
|
||||
device_tracker.jp_p3a_bt_entry:
|
||||
friendly_name: J.P. Pixel 3a
|
||||
source_type: bluetooth
|
||||
icon: mdi:bluetooth
|
||||
|
||||
device_tracker.jp_gs8_ap:
|
||||
friendly_name: J.P. Galaxy S8
|
||||
icon: mdi:wifi
|
||||
|
||||
device_tracker.jen_iphone_ip:
|
||||
friendly_name: Jen iPhone
|
||||
icon: mdi:wifi
|
||||
device_tracker.jen_iphone_bt_entry:
|
||||
friendly_name: Jen iPhone
|
||||
source_type: bluetooth
|
||||
icon: mdi:bluetooth
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Manual Setup Device Trackers
|
||||
device_tracker:
|
||||
# Proximity Sensors for Zones (deprecated in HA 2024.2)
|
||||
# proximity:
|
||||
# home:
|
||||
# devices:
|
||||
# - device_tracker.jen_iphone
|
||||
# - device_tracker.j_p_s_iphone
|
||||
# tolerance: 50
|
||||
# unit_of_measurement: km
|
||||
|
||||
# Ping Platform
|
||||
- platform: ping
|
||||
hosts:
|
||||
jp_p3a_ip: !secret jp_p3a_ip
|
||||
jp_gs8_ip: !secret jp_gs8_ip
|
||||
jen_iphone_ip: !secret jen_iphone_ip
|
||||
# jpk:
|
||||
# devices:
|
||||
# - device_tracker.j_p_s_iphone
|
||||
# tolerance: 50
|
||||
# unit_of_measurement: km
|
||||
|
||||
# MQTT Platform (Bluetooth Presence Sensor)
|
||||
- platform: mqtt
|
||||
devices:
|
||||
jp_gs8_bt_entry: monitor/entry/jp_gs8/device_tracker
|
||||
jp_p3a_bt_entry: monitor/entry/jp_p3a/device_tracker
|
||||
jen_iphone_bt_entry: monitor/entry/jen_iphone/device_tracker
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Proximity Sensors for Zones
|
||||
proximity:
|
||||
home:
|
||||
ignored_zones:
|
||||
- !secret jp_work_name
|
||||
- !secret jen_work_name
|
||||
devices:
|
||||
- device_tracker.jen_iphone
|
||||
- device_tracker.jp_pixel3a
|
||||
tolerance: 50
|
||||
unit_of_measurement: km
|
||||
# jen:
|
||||
# devices:
|
||||
# - device_tracker.jen_iphone
|
||||
# tolerance: 50
|
||||
# unit_of_measurement: km
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Extended Presence States
|
||||
@@ -147,34 +115,6 @@ sensor:
|
||||
# Automations for Presence Detection
|
||||
automation:
|
||||
|
||||
# Run Bluetooth Arrival Scan at Home Assistant startup
|
||||
- alias: Startup Arrival Scan
|
||||
trigger:
|
||||
- platform: homeassistant
|
||||
event: start
|
||||
mode: queued
|
||||
action:
|
||||
- service: mqtt.publish
|
||||
data:
|
||||
topic: monitor/scan/arrive
|
||||
payload: scan
|
||||
|
||||
# Run Bluetooth Departure Scan if a Ping Device Tracker goes Away
|
||||
- alias: Bluetooth Departure Scan
|
||||
trigger:
|
||||
- platform: homeassistant
|
||||
event: start
|
||||
- platform: state
|
||||
entity_id: binary_sensor.front_door_sensor
|
||||
- platform: time_pattern
|
||||
minutes: "/10"
|
||||
mode: single
|
||||
action:
|
||||
- service: mqtt.publish
|
||||
data:
|
||||
topic: monitor/scan/depart
|
||||
payload: scan
|
||||
|
||||
# Handle Just Arrived/Just Left State Transitions
|
||||
- alias: Mark Person as Just Arrived
|
||||
trigger:
|
||||
|
||||
@@ -19,3 +19,22 @@ This package contains configuration information for a Home Assistant powered hom
|
||||
| Bathroom Window | Bathroom | binary_sensor.bathroom_window |
|
||||
| Bedroom Side Window | Bedroom | binary_sensor.bedroom_side_window |
|
||||
| Bedroom Back Window | Bedroom | binary_sensor.bedroom_back_window |
|
||||
|
||||
## Camera Notes
|
||||
|
||||
The Amcrest AD410 doorbell camera expects to have an internet connection to use Amcrest's cloud services. If it detects it is disconnected, it will flash the ring light. The connection state can be read and set via the camera's REST interface. Since Home Assistant does not support digest authentication with the REST service, a command is used to set the connection state. The command is kept in a secret since it includes camera authentication data, but it uses the following cURL command:
|
||||
```shellscript
|
||||
$ curl -X POST --digest -u admin:{password} -v 'http://{camera_ip}/cgi-bin/configManager.cgi?action=setConfig&VSP_PaaS.Online=true'
|
||||
```
|
||||
|
||||
## Security States
|
||||
|
||||
The security state machine uses the following arming states:
|
||||
| Arming State | Trigger | Description |
|
||||
| --- | --- | --- |
|
||||
| `armed_home` | Manual | Manually set state |
|
||||
| `armed_away` | House Presence Change | Automatically set when the house presence is set to `Away` |
|
||||
| `armed_vacation` | House Presence Change | Automatically set when the house presence is `Extended Away` |
|
||||
| `armed_night` | Timed | Sundown to Sunup when home |
|
||||
|
||||
The `armed_home` and `armed_night` modes enable
|
||||
File diff suppressed because it is too large
Load Diff
3
packages/system/README.md
Normal file
3
packages/system/README.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# System Package
|
||||
|
||||
This package contains configuration information for the Home Assistant system itself, as well as various helper entities.
|
||||
31
packages/system/system.yaml
Normal file
31
packages/system/system.yaml
Normal file
@@ -0,0 +1,31 @@
|
||||
# System Package
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# System Scripts
|
||||
script system:
|
||||
toggle_group:
|
||||
alias: Toggle Group (Generic)
|
||||
description: Toggle the on/off state of a Group
|
||||
fields:
|
||||
entity_id:
|
||||
name: Group Entity
|
||||
description: Group entity to toggle
|
||||
required: true
|
||||
selector:
|
||||
entity:
|
||||
filter:
|
||||
- domain: group
|
||||
mode: queued
|
||||
sequence:
|
||||
- if:
|
||||
- condition: template
|
||||
value_template: >
|
||||
{{
|
||||
entity_id is defined
|
||||
and states(entity_id) != 'unknown'
|
||||
and states[entity_id].domain == 'group'
|
||||
}}
|
||||
then:
|
||||
- service: "homeassistant.turn_{{ iif(is_state(entity_id, 'off'), 'on', 'off') }}"
|
||||
target:
|
||||
entity_id: "{{ entity_id }}"
|
||||
3
packages/vacation/README.md
Normal file
3
packages/vacation/README.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# Vacation Package
|
||||
|
||||
This package contains automations and helpers for vacation mode.
|
||||
108
packages/vacation/vacation.yaml
Normal file
108
packages/vacation/vacation.yaml
Normal file
@@ -0,0 +1,108 @@
|
||||
# Vacation Package
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Vacation Helpers
|
||||
input_boolean:
|
||||
vacation_mode:
|
||||
name: Vacation Mode
|
||||
|
||||
input_number:
|
||||
# Threshold for minimum distance to trigger vacation mode (set to 0 to disable)
|
||||
vacation_proximity_threshold:
|
||||
name: Vacation Proximity Threshold
|
||||
mode: box
|
||||
min: 0
|
||||
max: 1000
|
||||
initial: 250
|
||||
step: 10
|
||||
unit_of_measurement: km
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Vacation Automations
|
||||
automation:
|
||||
- alias: Vacation - Turn On Vacation Mode
|
||||
trigger:
|
||||
- platform: state
|
||||
entity_id: input_select.house_presence_state
|
||||
to: 'Extended Away'
|
||||
- platform: state
|
||||
entity_id: alarm.alarm_control_panel
|
||||
to: armed_vacation
|
||||
- platform: template
|
||||
value_template: >-
|
||||
{{
|
||||
states('input_number.vacation_proximity_threshold')|default(0)|float > 0
|
||||
and states('proximity.home')|default(0)|float >= states('input_number.vacation_proximity_threshold')|default(0)|float
|
||||
}}
|
||||
action:
|
||||
- service: input_boolean.turn_on
|
||||
target:
|
||||
entity_id: input_boolean.vacation_mode
|
||||
|
||||
- alias: Vacation - Turn Off Vacation Mode
|
||||
trigger:
|
||||
- platform: state
|
||||
entity_id: input_select.house_presence_state
|
||||
to: 'Just Arrived'
|
||||
- platform: state
|
||||
entity_id: input_select.house_presence_state
|
||||
to: 'Home'
|
||||
action:
|
||||
- service: input_boolean.turn_off
|
||||
target:
|
||||
entity_id: input_boolean.vacation_mode
|
||||
|
||||
- alias: Vacation - Turn On Dining and Kitchen Lights at Sunset
|
||||
trigger:
|
||||
- platform: sun
|
||||
event: sunset
|
||||
offset: '-00:30:00'
|
||||
condition: "{{ is_state('input_select.house_presence_state', 'Extended Away') }}"
|
||||
action:
|
||||
- delay: "00:{{ range(0,59)|random|int }}:00"
|
||||
- service: light.turn_on
|
||||
target:
|
||||
entity_id: light.dining_room_light
|
||||
- service: light.turn_on
|
||||
target:
|
||||
entity_id: light.kitchen_light
|
||||
- delay: '02:00:00'
|
||||
- service: light.turn_off
|
||||
target:
|
||||
entity_id: light.dining_room_light
|
||||
- service: light.turn_off
|
||||
target:
|
||||
entity_id: light.kitchen_light
|
||||
|
||||
|
||||
- alias: Vacation - Turn On Living Room Lights after Dinner
|
||||
trigger:
|
||||
- platform: time
|
||||
at: '18:30:00'
|
||||
condition: "{{ is_state('input_select.house_presence_state', 'Extended Away') }}"
|
||||
action:
|
||||
- delay: "00:{{ range(0,59)|random|int }}:00"
|
||||
- service: light.turn_off
|
||||
target:
|
||||
entity_id: light.dining_room_light
|
||||
- service: light.turn_off
|
||||
target:
|
||||
entity_id: light.kitchen_light
|
||||
- service: light.turn_on
|
||||
target:
|
||||
entity_id: light.living_room_light
|
||||
- delay: '02:00:00'
|
||||
- service: light.turn_off
|
||||
target:
|
||||
entity_id: light.living_room_light
|
||||
|
||||
- alias: Vacation - Turn Off Living Room Lights at Bedtime
|
||||
trigger:
|
||||
- platform: time
|
||||
at: '20:30:00'
|
||||
condition: "{{ is_state('input_select.house_presence_state', 'Extended Away') }}"
|
||||
action:
|
||||
- delay: "00:{{ range(0,59)|random|int }}:00"
|
||||
- service: light.turn_off
|
||||
target:
|
||||
entity_id: light.living_room_light
|
||||
7
packages/vacuum/README.md
Normal file
7
packages/vacuum/README.md
Normal file
@@ -0,0 +1,7 @@
|
||||
# Vacuum Package
|
||||
|
||||
This package contains scripts and automations for running robot vacuums. Assumes the following entities are set up and configured with MQTT:
|
||||
|
||||
| Vacuum | Model | Entity |
|
||||
|---|---|---|
|
||||
| Furbot | Dreame D10s Plus | `vacuum.valetudo_furbot` |
|
||||
246
packages/vacuum/vacuum.yaml
Normal file
246
packages/vacuum/vacuum.yaml
Normal file
@@ -0,0 +1,246 @@
|
||||
# Vacuum Package
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Vacuum Helpers
|
||||
input_number:
|
||||
furbot_main_brush_life_hours:
|
||||
unit_of_measurement: h
|
||||
initial: 300
|
||||
min: 10
|
||||
max: 300
|
||||
mode: box
|
||||
|
||||
furbot_right_brush_life_hours:
|
||||
unit_of_measurement: h
|
||||
initial: 200
|
||||
min: 10
|
||||
max: 300
|
||||
mode: box
|
||||
|
||||
furbot_filter_life_hours:
|
||||
unit_of_measurement: h
|
||||
initial: 150
|
||||
min: 10
|
||||
max: 300
|
||||
mode: box
|
||||
|
||||
furbot_sensor_life_hours:
|
||||
unit_of_measurement: h
|
||||
initial: 30
|
||||
min: 10
|
||||
max: 300
|
||||
mode: box
|
||||
|
||||
input_text:
|
||||
furbot_last_fan_speed:
|
||||
name: Furbot Last Fan Speed
|
||||
initial: ""
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Room/Segment Helpers
|
||||
input_boolean:
|
||||
furbot_segment_dining_room:
|
||||
name: Dining Room
|
||||
icon: mdi:table-furniture
|
||||
|
||||
furbot_segment_kitchen:
|
||||
name: Kitchen
|
||||
icon: mdi:silverware
|
||||
|
||||
furbot_segment_laundry:
|
||||
name: Laundry Room
|
||||
icon: mdi:washing-machine
|
||||
|
||||
furbot_segment_entry:
|
||||
name: Entry
|
||||
icon: mdi:coat-rack
|
||||
|
||||
furbot_segment_office:
|
||||
name: Office
|
||||
icon: mdi:chair-rolling
|
||||
|
||||
furbot_segment_hallway:
|
||||
name: Hallway
|
||||
icon: mdi:shoe-print
|
||||
|
||||
furbot_segment_bathroom:
|
||||
name: Bathroom
|
||||
icon: mdi:shower
|
||||
|
||||
furbot_segment_bedroom:
|
||||
name: Bedroom
|
||||
icon: mdi:bed
|
||||
|
||||
furbot_segment_living_room:
|
||||
name: Living Room
|
||||
icon: mdi:television
|
||||
|
||||
furbot_segment_guest_room:
|
||||
name: Guest Room
|
||||
icon: mdi:account
|
||||
|
||||
homeassistant:
|
||||
customize:
|
||||
input_boolean.furbot_segment_bathroom:
|
||||
segment_id: '1'
|
||||
input_boolean.furbot_segment_bedroom:
|
||||
segment_id: '2'
|
||||
input_boolean.furbot_segment_guest_room:
|
||||
segment_id: '3'
|
||||
input_boolean.furbot_segment_office:
|
||||
segment_id: '4'
|
||||
input_boolean.furbot_segment_living_room:
|
||||
segment_id: '5'
|
||||
input_boolean.furbot_segment_laundry:
|
||||
segment_id: '6'
|
||||
input_boolean.furbot_segment_kitchen:
|
||||
segment_id: '7'
|
||||
input_boolean.furbot_segment_entry:
|
||||
segment_id: '8'
|
||||
input_boolean.furbot_segment_dining_room:
|
||||
segment_id: '9'
|
||||
input_boolean.furbot_segment_hallway:
|
||||
segment_id: '10'
|
||||
|
||||
group:
|
||||
furbot_segments:
|
||||
name: Furbot Rooms
|
||||
entities:
|
||||
- input_boolean.furbot_segment_bathroom
|
||||
- input_boolean.furbot_segment_bedroom
|
||||
- input_boolean.furbot_segment_guest_room
|
||||
- input_boolean.furbot_segment_office
|
||||
- input_boolean.furbot_segment_living_room
|
||||
- input_boolean.furbot_segment_laundry
|
||||
- input_boolean.furbot_segment_kitchen
|
||||
- input_boolean.furbot_segment_entry
|
||||
- input_boolean.furbot_segment_dining_room
|
||||
- input_boolean.furbot_segment_hallway
|
||||
|
||||
template:
|
||||
|
||||
# Consumable Helpers
|
||||
sensor:
|
||||
- name: Furbot Main Brush Life Pct
|
||||
state_class: measurement
|
||||
unit_of_measurement: "%"
|
||||
state: "{{ ((states('sensor.valetudo_furbot_main_brush')|default(120)|float) / (states('input_number.furbot_main_brush_life_hours')|float * 60) * 100) | round | int }}"
|
||||
|
||||
- name: Furbot Right Brush Life Pct
|
||||
state_class: measurement
|
||||
unit_of_measurement: "%"
|
||||
state: "{{ ((states('sensor.valetudo_furbot_right_brush')|default(120)|float) / (states('input_number.furbot_right_brush_life_hours')|float * 60) * 100) | round | int }}"
|
||||
|
||||
- name: Furbot Filter Life Pct
|
||||
state_class: measurement
|
||||
unit_of_measurement: "%"
|
||||
state: "{{ ((states('sensor.valetudo_furbot_main_filter')|default(120)|float) / (states('input_number.furbot_filter_life_hours')|float * 60) * 100) | round | int }}"
|
||||
|
||||
- name: Furbot Sensor Cleaning Pct
|
||||
state_class: measurement
|
||||
unit_of_measurement: "%"
|
||||
state: "{{ ((states('sensor.valetudo_furbot_sensor_cleaning')|default(120)|float) / (states('input_number.furbot_sensor_life_hours')|float * 60) * 100) | round | int }}"
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Vacuum Scripts
|
||||
script:
|
||||
|
||||
# Vacuum the bedroom (room ) only with high suction and two passes
|
||||
furbot_deep_clean_bedroom:
|
||||
alias: "Furbot: Deep Clean Bedroom"
|
||||
sequence:
|
||||
# Cache the fan setting and reset it when cleaning finishes
|
||||
- service: input_text.set_value
|
||||
target:
|
||||
entity_id: input_text.furbot_last_fan_speed
|
||||
data:
|
||||
value: "{{ states('select.valetudo_furbot_fan') }}"
|
||||
|
||||
# Start Cleaning
|
||||
- service: mqtt.publish
|
||||
data:
|
||||
topic: valetudo/furbot/FanSpeedControlCapability/preset/set
|
||||
payload: max
|
||||
|
||||
- service: mqtt.publish
|
||||
data:
|
||||
topic: valetudo/furbot/MapSegmentationCapability/clean/set
|
||||
payload: '{"segment_ids": ["2"], "iterations": 2}'
|
||||
|
||||
# Vacuum Selected Segments
|
||||
furbot_clean_segments:
|
||||
alias: "Furbot: Clean Selected Rooms"
|
||||
sequence:
|
||||
- choose:
|
||||
# Pause the robot if it is currently cleaning
|
||||
- conditions:
|
||||
- condition: template
|
||||
value_template: "{{ states('vacuum.valetudo_furbot') == 'cleaning' }}"
|
||||
sequence:
|
||||
- service: mqtt.publish
|
||||
data:
|
||||
topic: valetudo/furbot/BasicControlCapability/operation/set
|
||||
payload: 'PAUSE'
|
||||
|
||||
# Resume the robot if it is paused
|
||||
- conditions:
|
||||
- condition: template
|
||||
value_template: "{{ states('vacuum.valetudo_furbot') == 'paused' }}"
|
||||
sequence:
|
||||
- service: mqtt.publish
|
||||
data:
|
||||
topic: valetudo/furbot/BasicControlCapability/operation/set
|
||||
payload: 'START'
|
||||
|
||||
# Start a new cleaning if it is docked
|
||||
default:
|
||||
- if:
|
||||
- condition: template
|
||||
value_template: "{{ is_state('group.furbot_segments', 'off') }}"
|
||||
then:
|
||||
- service: mqtt.publish
|
||||
data:
|
||||
topic: valetudo/furbot/BasicControlCapability/operation/set
|
||||
payload: 'START'
|
||||
else:
|
||||
- service: mqtt.publish
|
||||
data:
|
||||
topic: valetudo/furbot/MapSegmentationCapability/clean/set
|
||||
payload: >-
|
||||
{
|
||||
"segment_ids": {{ expand('group.furbot_segments') |
|
||||
selectattr('state','eq','on') |
|
||||
map(attribute='attributes.segment_id') | list | to_json }}
|
||||
}
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Vacuum Automations
|
||||
automation:
|
||||
|
||||
# Reset the Fan Speed when cleaning is complete (if cached)
|
||||
- alias: "Furbot: Reset Fan Speed"
|
||||
trigger:
|
||||
- platform: state
|
||||
entity_id: vacuum.valetudo_furbot
|
||||
to: docked
|
||||
condition: "{{ states('input_text.furbot_last_fan_speed') != '' }}"
|
||||
action:
|
||||
- service: mqtt.publish
|
||||
data:
|
||||
topic: valetudo/furbot/FanSpeedControlCapability/preset/set
|
||||
payload_template: "{{ states('input_text.furbot_last_fan_speed') }}"
|
||||
|
||||
- service: input_text.reload
|
||||
target:
|
||||
entity_id: input_text.furbot_last_fan_speed
|
||||
|
||||
# Reset the Cleaning Segments when cleaning is complete
|
||||
- alias: "Furbot: Reset Rooms"
|
||||
trigger:
|
||||
- platform: state
|
||||
entity_id: vacuum.valetudo_furbot
|
||||
to: docked
|
||||
action:
|
||||
- service: homeassistant.turn_off
|
||||
target:
|
||||
entity_id: group.furbot_segments
|
||||
@@ -3,17 +3,33 @@
|
||||
# -----------------------------------------------------------------------------
|
||||
# Recorder Customization
|
||||
recorder:
|
||||
include:
|
||||
entities:
|
||||
- sensor.klgb_relative_humidity
|
||||
- sensor.klgb_temperature
|
||||
- sensor.klgb_dew_point
|
||||
- sensor.klgb_barometric_pressure
|
||||
- sensor.klgb_wind_direction
|
||||
- sensor.klgb_wind_speed
|
||||
exclude:
|
||||
domains:
|
||||
- weather
|
||||
entity_globs:
|
||||
- sensor.owm_*
|
||||
- sensor.klgb_*
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# InfluxDB Customization
|
||||
influxdb:
|
||||
include:
|
||||
entities:
|
||||
- sensor.klgb_relative_humidity
|
||||
- sensor.klgb_temperature
|
||||
- sensor.klgb_dew_point
|
||||
- sensor.klgb_barometric_pressure
|
||||
- sensor.klgb_wind_direction
|
||||
- sensor.klgb_wind_speed
|
||||
exclude:
|
||||
domains:
|
||||
- weather
|
||||
entity_globs:
|
||||
- sensor.owm_*
|
||||
- sensor.klgb_*
|
||||
|
||||
3
packages/winefridge/README.md
Normal file
3
packages/winefridge/README.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# Wine Fridge Package
|
||||
|
||||
This package contains automations to control the wine fridge outlet in the garage.
|
||||
68
packages/winefridge/winefridge.yaml
Normal file
68
packages/winefridge/winefridge.yaml
Normal file
@@ -0,0 +1,68 @@
|
||||
# Wine Fridge Package
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Wine Fridge Helpers
|
||||
input_number:
|
||||
wine_fridge_setpoint:
|
||||
name: Wine Fridge Set Point
|
||||
initial: 65
|
||||
min: 45
|
||||
max: 70
|
||||
step: 1
|
||||
unit_of_measurement: °F
|
||||
|
||||
wine_fridge_delta_t:
|
||||
name: Wine Fridge Delta T
|
||||
initial: 5
|
||||
min: 2
|
||||
max: 15
|
||||
unit_of_measurement: °F
|
||||
|
||||
template:
|
||||
binary_sensor:
|
||||
# Start wine fridge when dT is high enough to make the compressor happy
|
||||
- name: Wine Fridge Should Start
|
||||
state: >
|
||||
{{
|
||||
states('sensor.wine_fridge_sensor_temperature')|float > states('input_number.wine_fridge_setpoint')|float + 1
|
||||
and states('sensor.garage_sensor_air_temperature')|float > states('sensor.wine_fridge_sensor_temperature')|float + states('input_number.wine_fridge_delta_t')|float
|
||||
and states('switch.garage_wine_fridge_outlet') == 'off'
|
||||
}}
|
||||
|
||||
# Stop wine fridge when temperature falls and the compressor is stopped
|
||||
- name: Wine Fridge Should Stop
|
||||
state: >
|
||||
{{
|
||||
states('sensor.garage_sensor_air_temperature')|float < states('sensor.wine_fridge_sensor_temperature')|float + states('input_number.wine_fridge_delta_t')|float
|
||||
and states('sensor.garage_wine_fridge_outlet_electric_consumption_a')|float < 0.5
|
||||
and states('switch.garage_wine_fridge_outlet') == 'on'
|
||||
}}
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Wine Fridge Automations
|
||||
automation:
|
||||
- alias: Turn On Wine Fridge
|
||||
mode: single
|
||||
trigger:
|
||||
- platform: state
|
||||
entity_id: binary_sensor.wine_fridge_should_start
|
||||
to: 'on'
|
||||
for:
|
||||
minutes: 5
|
||||
action:
|
||||
- service: switch.turn_on
|
||||
target:
|
||||
entity_id: switch.garage_wine_fridge_outlet
|
||||
|
||||
- alias: Turn Off Wine Fridge
|
||||
mode: single
|
||||
trigger:
|
||||
- platform: state
|
||||
entity_id: binary_sensor.wine_fridge_should_stop
|
||||
to: 'on'
|
||||
for:
|
||||
minutes: 5
|
||||
action:
|
||||
- service: switch.turn_off
|
||||
target:
|
||||
entity_id: switch.garage_wine_fridge_outlet
|
||||
@@ -15,11 +15,11 @@ zone:
|
||||
radius: 250
|
||||
icon: mdi:briefcase
|
||||
|
||||
- name: !secret jen_work_name
|
||||
latitude: !secret jen_work_latitude
|
||||
longitude: !secret jen_work_longitude
|
||||
radius: 250
|
||||
icon: mdi:briefcase
|
||||
# - name: !secret jen_work_name
|
||||
# latitude: !secret jen_work_latitude
|
||||
# longitude: !secret jen_work_longitude
|
||||
# radius: 250
|
||||
# icon: mdi:briefcase
|
||||
|
||||
- name: Chorale (Performance)
|
||||
latitude: !secret chorale_perf_latitude
|
||||
|
||||
Reference in New Issue
Block a user