diff --git a/custom_components/alexa_media/.translations/fr.json b/custom_components/alexa_media/.translations/fr.json index 75932fee..7bc3c144 100644 --- a/custom_components/alexa_media/.translations/fr.json +++ b/custom_components/alexa_media/.translations/fr.json @@ -8,7 +8,7 @@ "error": { "2fa_key_invalid": "Clé 2FA intégrée non valide", "connection_error": "Erreur de connexion; vérifier le réseau et réessayer", - "hass_url_invalid": "Impossible de se connecter à l'URL de Home Assistant. Veuillez vérifier l'URL interne sous Configuration - > Général", + "hass_url_invalid": "Impossible de se connecter à l'URL de Home Assistant. Veuillez vérifier l'URL externe sous Configuration - > Général", "identifier_exists": "Email pour l'URL Alexa déjà enregistré", "invalid_credentials": "Informations d'identification invalides", "unknown_error": "Erreur inconnue, veuillez signaler les informations du journal" diff --git a/custom_components/alexa_media/.translations/it.json b/custom_components/alexa_media/.translations/it.json index de45a5c9..f7a88a20 100644 --- a/custom_components/alexa_media/.translations/it.json +++ b/custom_components/alexa_media/.translations/it.json @@ -8,7 +8,7 @@ "error": { "2fa_key_invalid": "Invalid Built-In 2FA key", "connection_error": "Errore durante la connessione; controlla la rete e riprova", - "hass_url_invalid": "Impossibile collegarsi ad Home Assistant. Controllare l'URL interno nel menu Configurazione -> Generale", + "hass_url_invalid": "Impossibile collegarsi ad Home Assistant. Controllare l'URL esterno nel menu Configurazione -> Generale", "identifier_exists": "L'Email per l'URL di Alexa è già stata registrata", "invalid_credentials": "Credenziali non valide", "unknown_error": "Errore sconosciuto, si prega di abilitare il debug avanzato e riportare i log informativi" diff --git a/custom_components/alexa_media/.translations/pt_PT.json b/custom_components/alexa_media/.translations/pt_PT.json index 4edcbca9..1197e578 100644 --- a/custom_components/alexa_media/.translations/pt_PT.json +++ b/custom_components/alexa_media/.translations/pt_PT.json @@ -8,7 +8,7 @@ "error": { "2fa_key_invalid": "Chave 2FA integrada inválida", "connection_error": "Erro ao conectar; verifique a rede e tente novamente", - "hass_url_invalid": "Não foi possível conectar ao URL do Home Assistant. Verifique o URL interno em Configuração - > Geral", + "hass_url_invalid": "Não foi possível conectar ao URL do Home Assistant. Verifique o URL externo em Configuração - > Geral", "identifier_exists": "E-mail para URL Alexa já registado", "invalid_credentials": "Credenciais inválidas", "unknown_error": "Erro desconhecido, por favor habilite depuração avançada e informações de log de relatório" diff --git a/custom_components/alexa_media/__init__.py b/custom_components/alexa_media/__init__.py index 5bbb7f2f..80b0db0b 100644 --- a/custom_components/alexa_media/__init__.py +++ b/custom_components/alexa_media/__init__.py @@ -621,16 +621,20 @@ async def async_update_data() -> Optional[AlexaEntityData]: device_registry, config_entry.entry_id ): for (_, identifier) in device_entry.identifiers: - if ( - identifier - in hass.data[DATA_ALEXAMEDIA]["accounts"][email]["devices"][ + if identifier in hass.data[DATA_ALEXAMEDIA]["accounts"][email][ + "devices" + ]["media_player"].keys() or identifier in map( + lambda x: slugify(f"{x}_{email}"), + hass.data[DATA_ALEXAMEDIA]["accounts"][email]["devices"][ "media_player" - ].keys() + ].keys(), ): break else: device_registry.async_remove_device(device_entry.id) - _LOGGER.debug("Removing stale device %s", device_entry.name) + _LOGGER.debug( + "%s: Removing stale device %s", hide_email(email), device_entry.name + ) await login_obj.save_cookiefile() if login_obj.access_token: @@ -656,7 +660,7 @@ async def process_notifications(login_obj, raw_notifications=None): previous = hass.data[DATA_ALEXAMEDIA]["accounts"][email].get( "notifications", {} ) - notifications = {"process_timestamp": datetime.utcnow()} + notifications = {"process_timestamp": dt.utcnow()} for notification in raw_notifications: n_dev_id = notification.get("deviceSerialNumber") if n_dev_id is None: diff --git a/custom_components/alexa_media/alexa_entity.py b/custom_components/alexa_media/alexa_entity.py index 780a811d..a028902c 100644 --- a/custom_components/alexa_media/alexa_entity.py +++ b/custom_components/alexa_media/alexa_entity.py @@ -264,9 +264,7 @@ def parse_color_from_coordinator( if value is not None: hue = value.get("hue", 0) saturation = value.get("saturation", 0) - brightness = parse_brightness_from_coordinator(coordinator, entity_id, since) - if brightness is not None: - return hue, saturation, brightness / 100 + return hue, saturation, 1 return None @@ -324,7 +322,7 @@ def is_cap_state_still_acceptable( if formatted_time_of_sample: try: time_of_sample = datetime.strptime( - formatted_time_of_sample, "%Y-%m-%dT%H:%M:%S.%fZ" + formatted_time_of_sample, "%Y-%m-%dT%H:%M:%S.%f%z" ) return time_of_sample >= since except ValueError: diff --git a/custom_components/alexa_media/const.py b/custom_components/alexa_media/const.py index b5088635..c812ed3f 100644 --- a/custom_components/alexa_media/const.py +++ b/custom_components/alexa_media/const.py @@ -8,7 +8,7 @@ """ from datetime import timedelta -__version__ = "3.10.4" +__version__ = "3.10.8" PROJECT_URL = "https://github.com/custom-components/alexa_media_player/" ISSUE_URL = f"{PROJECT_URL}issues" diff --git a/custom_components/alexa_media/light.py b/custom_components/alexa_media/light.py index 0c418b8f..369cf071 100644 --- a/custom_components/alexa_media/light.py +++ b/custom_components/alexa_media/light.py @@ -311,8 +311,8 @@ async def _set_state(self, power_on, brightness=None, mired=None, hs=None): self._requested_hs = None else: self._requested_hs = self.hs_color - self._requested_state_at = ( - datetime.datetime.utcnow() + self._requested_state_at = datetime.datetime.now( + datetime.timezone.utc ) # must be set last so that previous getters work properly self.async_write_ha_state() @@ -362,125 +362,150 @@ def alexa_brightness_to_ha(alexa: Optional[float]) -> Optional[float]: return (alexa / 100 * 255) if alexa is not None else None -# This is a fairly complete list of all the colors that Alexa will respond to. -# A couple weirder ones are skipped because the HA color utility don't know the RGB value -ALEXA_COLORS = [ - "crimson", - "dark_red", - "firebrick", - "orange_red", - "red", - "deep_pink", - "hot_pink", - "light_pink", - "maroon", - "medium_violet_red", - "pale_violet_red", - "pink", - "plum", - "tomato", - "chocolate", - "dark_orange", - "maroon", - "coral", - "light_coral", - "light_salmon", - "peru", - "salmon", - "sienna", - "gold", - "goldenrod", - "lime", - "olive", - "yellow", - "chartreuse", - "dark_green", - "dark_olive_green", - "dark_sea_green", - "forest_green", - "green", - "green_yellow", - "lawn_green", - "light_green", - "lime_green", - "medium_sea_green", - "medium_spring_green", - "olive_drab", - "pale_green", - "sea_green", - "spring_green", - "yellow_green", - "blue", - "cadet_blue", - "cyan", - "dark_blue", - "dark_cyan", - "dark_slate_blue", - "dark_turquoise", - "deep_sky_blue", - "dodger_blue", - "light_blue", - "light_sea_green", - "light_sky_blue", - "medium_blue", - "medium_turquoise", - "midnight_blue", - "navy_blue", - "pale_turquoise", - "powder_blue", - "royal_blue", - "sky_blue", - "slate_blue", - "steel_blue", - "teal", - "turquoise", - "blue_violet", - "dark_magenta", - "dark_orchid", - "dark_violet", - "fuchsia", - "indigo", - "lavender", - "magenta", - "medium_orchid", - "medium_purple", - "orchid", - "purple", - "rosy_brown", - "violet", - "alice_blue", - "antique_white", - "blanched_almond", - "cornsilk", - "dark_khaki", - "floral_white", - "gainsboro", - "ghost_white", - "honeydew", - "ivory", - "khaki", - "lavender_blush", - "lemon_chiffon", - "light_cyan", - "light_steel_blue", - "light_yellow", - "linen", - "mint_cream", - "misty_rose", - "moccasin", - "old_lace", - "pale_goldenrod", - "papaya_whip", - "peach_puff", - "seashell", - "silver", - "snow", - "tan", - "thistle", - "wheat", - "white", - "white_smoke", -] +# This is a fairly complete list of all the colors that Alexa will respond to and their associated RGB value. +ALEXA_COLORS = { + "alice_blue": (240, 248, 255), + "antique_white": (250, 235, 215), + "aqua": (0, 255, 255), + "aquamarine": (127, 255, 212), + "azure": (240, 255, 255), + "beige": (245, 245, 220), + "bisque": (255, 228, 196), + "black": (0, 0, 0), + "blanched_almond": (255, 235, 205), + "blue": (0, 0, 255), + "blue_violet": (138, 43, 226), + "brown": (165, 42, 42), + "burlywood": (222, 184, 135), + "cadet_blue": (95, 158, 160), + "chartreuse": (127, 255, 0), + "chocolate": (210, 105, 30), + "coral": (255, 127, 80), + "cornflower_blue": (100, 149, 237), + "cornsilk": (255, 248, 220), + "crimson": (220, 20, 60), + "cyan": (0, 255, 255), + "dark_blue": (0, 0, 139), + "dark_cyan": (0, 139, 139), + "dark_goldenrod": (184, 134, 11), + "dark_green": (0, 100, 0), + "dark_grey": (169, 169, 169), + "dark_khaki": (189, 183, 107), + "dark_magenta": (139, 0, 139), + "dark_olive_green": (85, 107, 47), + "dark_orange": (255, 140, 0), + "dark_orchid": (153, 50, 204), + "dark_red": (139, 0, 0), + "dark_salmon": (233, 150, 122), + "dark_sea_green": (143, 188, 143), + "dark_slate_blue": (72, 61, 139), + "dark_slate_grey": (47, 79, 79), + "dark_turquoise": (0, 206, 209), + "dark_violet": (148, 0, 211), + "deep_pink": (255, 20, 147), + "deep_sky_blue": (0, 191, 255), + "dim_grey": (105, 105, 105), + "dodger_blue": (30, 144, 255), + "firebrick": (178, 34, 34), + "floral_white": (255, 250, 240), + "forest_green": (34, 139, 34), + "fuchsia": (255, 0, 255), + "gainsboro": (220, 220, 220), + "ghost_white": (248, 248, 255), + "gold": (255, 215, 0), + "goldenrod": (218, 165, 32), + "green": (0, 128, 0), + "green_yellow": (173, 255, 47), + "grey": (128, 128, 128), + "honey_dew": (240, 255, 240), + "hot_pink": (255, 105, 180), + "indian_red": (205, 92, 92), + "indigo": (75, 0, 130), + "ivory": (255, 255, 240), + "khaki": (240, 230, 140), + "lavender": (230, 230, 250), + "lavender_blush": (255, 240, 245), + "lawn_green": (124, 252, 0), + "lemon_chiffon": (255, 250, 205), + "light_blue": (173, 216, 230), + "light_coral": (240, 128, 128), + "light_cyan": (224, 255, 255), + "light_goldenrod_yellow": (250, 250, 210), + "light_green": (144, 238, 144), + "light_grey": (211, 211, 211), + "light_pink": (255, 182, 193), + "light_salmon": (255, 160, 122), + "light_sea_green": (32, 178, 170), + "light_sky_blue": (135, 206, 250), + "light_slate_grey": (119, 136, 153), + "light_steel_blue": (176, 196, 222), + "light_yellow": (255, 255, 224), + "lime": (0, 255, 0), + "lime_green": (50, 205, 50), + "linen": (250, 240, 230), + "magenta": (255, 0, 255), + "maroon": (128, 0, 0), + "medium_aqua_marine": (102, 205, 170), + "medium_blue": (0, 0, 205), + "medium_orchid": (186, 85, 211), + "medium_purple": (147, 112, 219), + "medium_sea_green": (60, 179, 113), + "medium_slate_blue": (123, 104, 238), + "medium_spring_green": (0, 250, 154), + "medium_turquoise": (72, 209, 204), + "medium_violet_red": (199, 21, 133), + "midnight_blue": (25, 25, 112), + "mint_cream": (245, 255, 250), + "misty_rose": (255, 228, 225), + "moccasin": (255, 228, 181), + "navajo_white": (255, 222, 173), + "navy": (0, 0, 128), + "old_lace": (253, 245, 230), + "olive": (128, 128, 0), + "olive_drab": (107, 142, 35), + "orange": (255, 165, 0), + "orange_red": (255, 69, 0), + "orchid": (218, 112, 214), + "pale_goldenrod": (238, 232, 170), + "pale_green": (152, 251, 152), + "pale_turquoise": (175, 238, 238), + "pale_violet_red": (219, 112, 147), + "papaya_whip": (255, 239, 213), + "peach_puff": (255, 218, 185), + "peru": (205, 133, 63), + "pink": (255, 192, 203), + "plum": (221, 160, 221), + "powder_blue": (176, 224, 230), + "purple": (128, 0, 128), + "rebecca_purple": (102, 51, 153), + "red": (255, 0, 0), + "rosy_brown": (188, 143, 143), + "royal_blue": (65, 105, 225), + "saddle_brown": (139, 69, 19), + "salmon": (250, 128, 114), + "sandy_brown": (244, 164, 96), + "sea_green": (46, 139, 87), + "sea_shell": (255, 245, 238), + "sienna": (160, 82, 45), + "silver": (192, 192, 192), + "sky_blue": (135, 206, 235), + "slate_blue": (106, 90, 205), + "slate_grey": (112, 128, 144), + "snow": (255, 250, 250), + "spring_green": (0, 255, 127), + "steel_blue": (70, 130, 180), + "tan": (210, 180, 140), + "teal": (0, 128, 128), + "thistle": (216, 191, 216), + "tomato": (255, 99, 71), + "turquoise": (64, 224, 208), + "violet": (238, 130, 238), + "wheat": (245, 222, 179), + "white": (255, 255, 255), + "white_smoke": (245, 245, 245), + "yellow": (255, 255, 0), + "yellow_green": (154, 205, 50), +} def red_mean(color1: Tuple[int, int, int], color2: Tuple[int, int, int]) -> float: @@ -506,11 +531,11 @@ def rgb_to_alexa_color( rgb: Tuple[int, int, int] ) -> Tuple[Optional[Tuple[float, float]], Optional[Text]]: """Convert a given RGB value into the closest Alexa color.""" - name = min( - ALEXA_COLORS, - key=lambda color_name: red_mean(rgb, alexa_color_name_to_rgb(color_name)), + (name, alexa_rgb) = min( + ALEXA_COLORS.items(), + key=lambda alexa_color: red_mean(alexa_color[1], rgb), ) - red, green, blue = alexa_color_name_to_rgb(name) + red, green, blue = alexa_rgb return color_RGB_to_hs(red, green, blue), name diff --git a/custom_components/alexa_media/manifest.json b/custom_components/alexa_media/manifest.json index eaaddbbe..c506a1db 100644 --- a/custom_components/alexa_media/manifest.json +++ b/custom_components/alexa_media/manifest.json @@ -1,7 +1,7 @@ { "domain": "alexa_media", "name": "Alexa Media Player", - "version": "3.10.4", + "version": "3.10.8", "config_flow": true, "documentation": "https://github.com/custom-components/alexa_media_player/wiki", "issue_tracker": "https://github.com/custom-components/alexa_media_player/issues", diff --git a/custom_components/alexa_media/sensor.py b/custom_components/alexa_media/sensor.py index 0166adac..7030e251 100644 --- a/custom_components/alexa_media/sensor.py +++ b/custom_components/alexa_media/sensor.py @@ -370,7 +370,7 @@ def _update_recurring_alarm(self, value): while ( alarm_on and recurring_pattern - and RECURRING_PATTERN_ISO_SET[recurring_pattern] + and RECURRING_PATTERN_ISO_SET.get(recurring_pattern) and alarm.isoweekday not in RECURRING_PATTERN_ISO_SET[recurring_pattern] and alarm < dt.now() ): @@ -538,9 +538,7 @@ def device_state_attributes(self): attr = { "recurrence": self.recurrence, - "process_timestamp": dt.as_local( - datetime.datetime.fromtimestamp(self._timestamp.timestamp()) - ).isoformat(), + "process_timestamp": dt.as_local(self._timestamp).isoformat(), "prior_value": self._process_state(self._prior_value), "total_active": len(self._active), "total_all": len(self._all), @@ -586,10 +584,8 @@ def _process_state(self, value): return ( dt.as_local( super()._round_time( - datetime.datetime.fromtimestamp( - self._timestamp.timestamp() - + value[self._sensor_property] / 1000 - ) + self._timestamp + + datetime.timedelta(milliseconds=value[self._sensor_property]) ) ).isoformat() if value and self._timestamp diff --git a/custom_components/alexa_media/translations/fr.json b/custom_components/alexa_media/translations/fr.json index 75932fee..7bc3c144 100644 --- a/custom_components/alexa_media/translations/fr.json +++ b/custom_components/alexa_media/translations/fr.json @@ -8,7 +8,7 @@ "error": { "2fa_key_invalid": "Clé 2FA intégrée non valide", "connection_error": "Erreur de connexion; vérifier le réseau et réessayer", - "hass_url_invalid": "Impossible de se connecter à l'URL de Home Assistant. Veuillez vérifier l'URL interne sous Configuration - > Général", + "hass_url_invalid": "Impossible de se connecter à l'URL de Home Assistant. Veuillez vérifier l'URL externe sous Configuration - > Général", "identifier_exists": "Email pour l'URL Alexa déjà enregistré", "invalid_credentials": "Informations d'identification invalides", "unknown_error": "Erreur inconnue, veuillez signaler les informations du journal" diff --git a/custom_components/alexa_media/translations/it.json b/custom_components/alexa_media/translations/it.json index de45a5c9..f7a88a20 100644 --- a/custom_components/alexa_media/translations/it.json +++ b/custom_components/alexa_media/translations/it.json @@ -8,7 +8,7 @@ "error": { "2fa_key_invalid": "Invalid Built-In 2FA key", "connection_error": "Errore durante la connessione; controlla la rete e riprova", - "hass_url_invalid": "Impossibile collegarsi ad Home Assistant. Controllare l'URL interno nel menu Configurazione -> Generale", + "hass_url_invalid": "Impossibile collegarsi ad Home Assistant. Controllare l'URL esterno nel menu Configurazione -> Generale", "identifier_exists": "L'Email per l'URL di Alexa è già stata registrata", "invalid_credentials": "Credenziali non valide", "unknown_error": "Errore sconosciuto, si prega di abilitare il debug avanzato e riportare i log informativi" diff --git a/custom_components/alexa_media/translations/pt_PT.json b/custom_components/alexa_media/translations/pt_PT.json index 4edcbca9..1197e578 100644 --- a/custom_components/alexa_media/translations/pt_PT.json +++ b/custom_components/alexa_media/translations/pt_PT.json @@ -8,7 +8,7 @@ "error": { "2fa_key_invalid": "Chave 2FA integrada inválida", "connection_error": "Erro ao conectar; verifique a rede e tente novamente", - "hass_url_invalid": "Não foi possível conectar ao URL do Home Assistant. Verifique o URL interno em Configuração - > Geral", + "hass_url_invalid": "Não foi possível conectar ao URL do Home Assistant. Verifique o URL externo em Configuração - > Geral", "identifier_exists": "E-mail para URL Alexa já registado", "invalid_credentials": "Credenciais inválidas", "unknown_error": "Erro desconhecido, por favor habilite depuração avançada e informações de log de relatório" diff --git a/custom_components/dwd_weather/connector.py b/custom_components/dwd_weather/connector.py index c350e998..c98f4358 100644 --- a/custom_components/dwd_weather/connector.py +++ b/custom_components/dwd_weather/connector.py @@ -272,11 +272,11 @@ def get_hourly(self, data_type: WeatherDataType): elif data_type == WeatherDataType.PRESSURE: value = round(value / 100, 1) elif data_type == WeatherDataType.WIND_SPEED: - value = round(value, 1) + value = round(value * 3.6, 1) elif data_type == WeatherDataType.WIND_DIRECTION: value = round(value, 0) elif data_type == WeatherDataType.WIND_GUSTS: - value = round(value, 1) + value = round(value * 3.6, 1) elif data_type == WeatherDataType.PRECIPITATION: value = round(value, 1) elif data_type == WeatherDataType.PRECIPITATION_PROBABILITY: diff --git a/custom_components/dwd_weather/manifest.json b/custom_components/dwd_weather/manifest.json index ae201bdb..16d49775 100644 --- a/custom_components/dwd_weather/manifest.json +++ b/custom_components/dwd_weather/manifest.json @@ -1,6 +1,6 @@ { "domain": "dwd_weather", - "version": "1.2.11", + "version": "1.2.15", "name": "Deutscher Wetterdienst (DWD)", "documentation": "https://github.com/FL550/dwd_weather", "issue_tracker": "https://github.com/FL550/dwd_weather/issues", @@ -9,5 +9,5 @@ "codeowners": [ "@FL550" ], - "requirements": ["simple_dwd_weatherforecast==1.0.17","markdownify==0.6.5"] + "requirements": ["simple_dwd_weatherforecast==1.1.1","markdownify==0.6.5"] } diff --git a/custom_components/dwd_weather/sensor.py b/custom_components/dwd_weather/sensor.py index 475c10a4..0adf8882 100644 --- a/custom_components/dwd_weather/sensor.py +++ b/custom_components/dwd_weather/sensor.py @@ -99,7 +99,7 @@ "sun_irradiance": [ "Sun Irradiance", None, - "kJ/m^2", + "W/m^2", "mdi:weather-sunny-alert", False, ], diff --git a/custom_components/hacs/__init__.py b/custom_components/hacs/__init__.py index 5adcd488..db07e044 100644 --- a/custom_components/hacs/__init__.py +++ b/custom_components/hacs/__init__.py @@ -4,29 +4,65 @@ For more details about this integration, please refer to the documentation at https://hacs.xyz/ """ +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant import voluptuous as vol -from .const import DOMAIN +from .const import DOMAIN, PLATFORMS +from .enums import HacsDisabledReason from .helpers.functions.configuration_schema import hacs_config_combined -from .operational.setup import async_setup as hacs_yaml_setup -from .operational.setup import async_setup_entry as hacs_ui_setup -from .operational.remove import async_remove_entry as hacs_remove_entry +from .operational.setup import ( + async_setup as hacs_yaml_setup, + async_setup_entry as hacs_ui_setup, +) + +if TYPE_CHECKING: + from .base import HacsBase CONFIG_SCHEMA = vol.Schema({DOMAIN: hacs_config_combined()}, extra=vol.ALLOW_EXTRA) -async def async_setup(hass, config): +async def async_setup(hass: HomeAssistant, config: dict[str, Any]) -> bool: """Set up this integration using yaml.""" return await hacs_yaml_setup(hass, config) -async def async_setup_entry(hass, config_entry): +async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: """Set up this integration using UI.""" + config_entry.add_update_listener(async_reload_entry) return await hacs_ui_setup(hass, config_entry) -async def async_remove_entry(hass, config_entry): +async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: """Handle removal of an entry.""" - return await hacs_remove_entry(hass, config_entry) + hacs: HacsBase = hass.data[DOMAIN] + + for task in hacs.recuring_tasks: + # Cancel all pending tasks + task() + + try: + if hass.data.get("frontend_panels", {}).get("hacs"): + hacs.log.info("Removing sidepanel") + hass.components.frontend.async_remove_panel("hacs") + except AttributeError: + pass + + unload_ok = await hass.config_entries.async_unload_platforms(config_entry, PLATFORMS) + + hacs.disable_hacs(HacsDisabledReason.REMOVED) + hass.data.pop(DOMAIN, None) + + return unload_ok + + +async def async_reload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> None: + """Reload the HACS config entry.""" + await async_unload_entry(hass, config_entry) + await async_setup_entry(hass, config_entry) diff --git a/custom_components/hacs/api/acknowledge_critical_repository.py b/custom_components/hacs/api/acknowledge_critical_repository.py index 02235b21..f0d69bd9 100644 --- a/custom_components/hacs/api/acknowledge_critical_repository.py +++ b/custom_components/hacs/api/acknowledge_critical_repository.py @@ -1,7 +1,7 @@ """API Handler for acknowledge_critical_repository""" +from homeassistant.components import websocket_api import homeassistant.helpers.config_validation as cv import voluptuous as vol -from homeassistant.components import websocket_api from custom_components.hacs.helpers.functions.store import ( async_load_from_store, diff --git a/custom_components/hacs/api/check_local_path.py b/custom_components/hacs/api/check_local_path.py index 308b81be..d45526bb 100644 --- a/custom_components/hacs/api/check_local_path.py +++ b/custom_components/hacs/api/check_local_path.py @@ -1,7 +1,7 @@ """API Handler for check_local_path""" +from homeassistant.components import websocket_api import homeassistant.helpers.config_validation as cv import voluptuous as vol -from homeassistant.components import websocket_api from custom_components.hacs.helpers.functions.path_exsist import async_path_exsist diff --git a/custom_components/hacs/api/get_critical_repositories.py b/custom_components/hacs/api/get_critical_repositories.py index 35f2c946..23fdcf4d 100644 --- a/custom_components/hacs/api/get_critical_repositories.py +++ b/custom_components/hacs/api/get_critical_repositories.py @@ -1,6 +1,6 @@ """API Handler for get_critical_repositories""" -import voluptuous as vol from homeassistant.components import websocket_api +import voluptuous as vol from custom_components.hacs.helpers.functions.store import async_load_from_store diff --git a/custom_components/hacs/api/hacs_config.py b/custom_components/hacs/api/hacs_config.py index 1760ee8f..35609a6d 100644 --- a/custom_components/hacs/api/hacs_config.py +++ b/custom_components/hacs/api/hacs_config.py @@ -1,6 +1,6 @@ """API Handler for hacs_config""" -import voluptuous as vol from homeassistant.components import websocket_api +import voluptuous as vol from custom_components.hacs.share import get_hacs diff --git a/custom_components/hacs/api/hacs_removed.py b/custom_components/hacs/api/hacs_removed.py index 28628e87..7ede24e9 100644 --- a/custom_components/hacs/api/hacs_removed.py +++ b/custom_components/hacs/api/hacs_removed.py @@ -1,6 +1,6 @@ """API Handler for hacs_removed""" -import voluptuous as vol from homeassistant.components import websocket_api +import voluptuous as vol from custom_components.hacs.share import list_removed_repositories diff --git a/custom_components/hacs/api/hacs_repositories.py b/custom_components/hacs/api/hacs_repositories.py index e0033427..d4d79f5d 100644 --- a/custom_components/hacs/api/hacs_repositories.py +++ b/custom_components/hacs/api/hacs_repositories.py @@ -1,6 +1,6 @@ """API Handler for hacs_repositories""" -import voluptuous as vol from homeassistant.components import websocket_api +import voluptuous as vol from custom_components.hacs.share import get_hacs @@ -13,7 +13,10 @@ async def hacs_repositories(_hass, connection, msg): repositories = hacs.repositories content = [] for repo in repositories: - if repo.data.category in hacs.common.categories: + if ( + repo.data.category in hacs.common.categories + and not repo.ignored_by_country_configuration + ): data = { "additional_info": repo.information.additional_info, "authors": repo.data.authors, diff --git a/custom_components/hacs/api/hacs_repository.py b/custom_components/hacs/api/hacs_repository.py index 6921db43..bfa00234 100644 --- a/custom_components/hacs/api/hacs_repository.py +++ b/custom_components/hacs/api/hacs_repository.py @@ -1,11 +1,11 @@ """API Handler for hacs_repository""" -import homeassistant.helpers.config_validation as cv -import voluptuous as vol from aiogithubapi import AIOGitHubAPIException from homeassistant.components import websocket_api +import homeassistant.helpers.config_validation as cv +import voluptuous as vol -from custom_components.hacs.helpers.functions.logger import getLogger from custom_components.hacs.share import get_hacs +from custom_components.hacs.utils.logger import getLogger @websocket_api.async_response diff --git a/custom_components/hacs/api/hacs_repository_data.py b/custom_components/hacs/api/hacs_repository_data.py index 3052e72d..123d8dd2 100644 --- a/custom_components/hacs/api/hacs_repository_data.py +++ b/custom_components/hacs/api/hacs_repository_data.py @@ -1,18 +1,18 @@ """API Handler for hacs_repository_data""" import sys -import homeassistant.helpers.config_validation as cv -import voluptuous as vol from aiogithubapi import AIOGitHubAPIException from homeassistant.components import websocket_api +import homeassistant.helpers.config_validation as cv +import voluptuous as vol -from custom_components.hacs.helpers.classes.exceptions import HacsException -from custom_components.hacs.helpers.functions.logger import getLogger +from custom_components.hacs.exceptions import HacsException from custom_components.hacs.helpers.functions.misc import extract_repository_from_url from custom_components.hacs.helpers.functions.register_repository import ( register_repository, ) from custom_components.hacs.share import get_hacs +from custom_components.hacs.utils.logger import getLogger _LOGGER = getLogger() diff --git a/custom_components/hacs/api/hacs_settings.py b/custom_components/hacs/api/hacs_settings.py index fe076348..7af3468c 100644 --- a/custom_components/hacs/api/hacs_settings.py +++ b/custom_components/hacs/api/hacs_settings.py @@ -1,10 +1,10 @@ """API Handler for hacs_settings""" +from homeassistant.components import websocket_api import homeassistant.helpers.config_validation as cv import voluptuous as vol -from homeassistant.components import websocket_api -from custom_components.hacs.helpers.functions.logger import getLogger from custom_components.hacs.share import get_hacs +from custom_components.hacs.utils.logger import getLogger _LOGGER = getLogger() diff --git a/custom_components/hacs/api/hacs_status.py b/custom_components/hacs/api/hacs_status.py index f840cc4b..15ce85cb 100644 --- a/custom_components/hacs/api/hacs_status.py +++ b/custom_components/hacs/api/hacs_status.py @@ -1,6 +1,6 @@ """API Handler for hacs_status""" -import voluptuous as vol from homeassistant.components import websocket_api +import voluptuous as vol from custom_components.hacs.share import get_hacs @@ -13,7 +13,7 @@ async def hacs_status(_hass, connection, msg): content = { "startup": hacs.status.startup, "background_task": hacs.status.background_task, - "lovelace_mode": hacs.system.lovelace_mode, + "lovelace_mode": hacs.core.lovelace_mode, "reloading_data": hacs.status.reloading_data, "upgrading_all": hacs.status.upgrading_all, "disabled": hacs.system.disabled, diff --git a/custom_components/hacs/base.py b/custom_components/hacs/base.py index 5df325d2..54d5759a 100644 --- a/custom_components/hacs/base.py +++ b/custom_components/hacs/base.py @@ -1,32 +1,119 @@ """Base HACS class.""" +from __future__ import annotations + +from dataclasses import asdict, dataclass, field +import json import logging -from typing import List, Optional, TYPE_CHECKING +import math import pathlib - -import attr -from aiogithubapi.github import AIOGitHubAPI +from typing import TYPE_CHECKING, Any + +from aiogithubapi import ( + GitHub, + GitHubAPI, + GitHubAuthenticationException, + GitHubRatelimitException, +) from aiogithubapi.objects.repository import AIOGitHubAPIRepository +from aiohttp.client import ClientSession from homeassistant.core import HomeAssistant - -from .enums import HacsDisabledReason, HacsStage -from .helpers.functions.logger import getLogger -from .models.core import HacsCore -from .models.frontend import HacsFrontend -from .models.system import HacsSystem +from homeassistant.loader import Integration +from queueman.manager import QueueManager + +from .const import REPOSITORY_HACS_DEFAULT +from .enums import ( + ConfigurationType, + HacsCategory, + HacsDisabledReason, + HacsStage, + LovelaceMode, +) +from .exceptions import HacsException +from .utils.decode import decode_content +from .utils.logger import getLogger if TYPE_CHECKING: + from .hacsbase.data import HacsData from .helpers.classes.repository import HacsRepository - - + from .operational.factory import HacsTaskFactory + from .tasks.manager import HacsTaskManager + + +@dataclass +class HacsConfiguration: + """HacsConfiguration class.""" + + appdaemon_path: str = "appdaemon/apps/" + appdaemon: bool = False + config: dict[str, Any] = field(default_factory=dict) + config_entry: dict[str, str] = field(default_factory=dict) + config_type: ConfigurationType | None = None + country: str = "ALL" + debug: bool = False + dev: bool = False + experimental: bool = False + frontend_compact: bool = False + frontend_mode: str = "Grid" + frontend_repo_url: str = "" + frontend_repo: str = "" + netdaemon_path: str = "netdaemon/apps/" + netdaemon: bool = False + onboarding_done: bool = False + plugin_path: str = "www/community/" + python_script_path: str = "python_scripts/" + python_script: bool = False + release_limit: int = 5 + sidepanel_icon: str = "hacs:hacs" + sidepanel_title: str = "HACS" + theme_path: str = "themes/" + theme: bool = False + token: str = None + + def to_json(self) -> str: + """Return a json string.""" + return asdict(self) + + def update_from_dict(self, data: dict) -> None: + """Set attributes from dicts.""" + if not isinstance(data, dict): + raise HacsException("Configuration is not valid.") + + for key in data: + self.__setattr__(key, data[key]) + + +@dataclass +class HacsFrontend: + """HacsFrontend.""" + + version_running: str | None = None + version_available: str | None = None + version_expected: str | None = None + update_pending: bool = False + + +@dataclass +class HacsCore: + """HACS Core info.""" + + config_path: pathlib.Path | None = None + ha_version: str | None = None + lovelace_mode = LovelaceMode("yaml") + + +@dataclass class HacsCommon: """Common for HACS.""" - categories: List = [] - default: List = [] - installed: List = [] - skip: List = [] + categories: set[str] = field(default_factory=set) + default: list[str] = field(default_factory=list) + installed: list[str] = field(default_factory=list) + renamed_repositories: dict[str, str] = field(default_factory=dict) + archived_repositories: list[str] = field(default_factory=list) + skip: list[str] = field(default_factory=list) +@dataclass class HacsStatus: """HacsStatus.""" @@ -37,93 +124,111 @@ class HacsStatus: upgrading_all: bool = False -@attr.s -class HacsBaseAttributes: - """Base HACS class.""" +@dataclass +class HacsSystem: + """HACS System info.""" - _default: Optional[AIOGitHubAPIRepository] - _github: Optional[AIOGitHubAPI] - _hass: Optional[HomeAssistant] - _repository: Optional[AIOGitHubAPIRepository] - _stage: HacsStage = HacsStage.SETUP - _common: Optional[HacsCommon] - - core: HacsCore = attr.ib(HacsCore) - common: HacsCommon = attr.ib(HacsCommon) - status: HacsStatus = attr.ib(HacsStatus) - frontend: HacsFrontend = attr.ib(HacsFrontend) - log: logging.Logger = getLogger() - system: HacsSystem = attr.ib(HacsSystem) - repositories: List["HacsRepository"] = [] + disabled: bool = False + disabled_reason: str | None = None + running: bool = False + stage = HacsStage.SETUP + action: bool = False -@attr.s -class HacsBase(HacsBaseAttributes): +class HacsBase: """Base HACS class.""" - @property - def stage(self) -> HacsStage: - """Returns a HacsStage object.""" - return self._stage - - @stage.setter - def stage(self, value: HacsStage) -> None: - """Set the value for the stage property.""" - self._stage = value - - @property - def github(self) -> Optional[AIOGitHubAPI]: - """Returns a AIOGitHubAPI object.""" - return self._github - - @github.setter - def github(self, value: AIOGitHubAPI) -> None: - """Set the value for the github property.""" - self._github = value - - @property - def repository(self) -> Optional[AIOGitHubAPIRepository]: - """Returns a AIOGitHubAPIRepository object representing hacs/integration.""" - return self._repository - - @repository.setter - def repository(self, value: AIOGitHubAPIRepository) -> None: - """Set the value for the repository property.""" - self._repository = value - - @property - def default(self) -> Optional[AIOGitHubAPIRepository]: - """Returns a AIOGitHubAPIRepository object representing hacs/default.""" - return self._default - - @default.setter - def default(self, value: AIOGitHubAPIRepository) -> None: - """Set the value for the default property.""" - self._default = value - - @property - def hass(self) -> Optional[HomeAssistant]: - """Returns a HomeAssistant object.""" - return self._hass - - @hass.setter - def hass(self, value: HomeAssistant) -> None: - """Set the value for the default property.""" - self._hass = value + _repositories = [] + _repositories_by_full_name = {} + _repositories_by_id = {} + + common = HacsCommon() + configuration = HacsConfiguration() + core = HacsCore() + data: HacsData | None = None + factory: HacsTaskFactory | None = None + frontend = HacsFrontend() + github: GitHub | None = None + githubapi: GitHubAPI | None = None + hass: HomeAssistant | None = None + integration: Integration | None = None + log: logging.Logger = getLogger() + queue: QueueManager | None = None + recuring_tasks = [] + repositories: list[HacsRepository] = [] + repository: AIOGitHubAPIRepository | None = None + session: ClientSession | None = None + stage: HacsStage | None = None + status = HacsStatus() + system = HacsSystem() + tasks: HacsTaskManager | None = None + version: str | None = None @property def integration_dir(self) -> pathlib.Path: """Return the HACS integration dir.""" - return pathlib.Path(__file__).parent + return self.integration.file_path - def disable(self, reason: HacsDisabledReason) -> None: + async def async_set_stage(self, stage: HacsStage | None) -> None: + """Set HACS stage.""" + if stage and self.stage == stage: + return + + self.stage = stage + if stage is not None: + self.log.info("Stage changed: %s", self.stage) + self.hass.bus.async_fire("hacs/stage", {"stage": self.stage}) + await self.tasks.async_execute_runtume_tasks() + + def disable_hacs(self, reason: HacsDisabledReason) -> None: """Disable HACS.""" self.system.disabled = True self.system.disabled_reason = reason - self.log.error("HACS is disabled - %s", reason) + if reason != HacsDisabledReason.REMOVED: + self.log.error("HACS is disabled - %s", reason) - def enable(self) -> None: + def enable_hacs(self) -> None: """Enable HACS.""" self.system.disabled = False self.system.disabled_reason = None self.log.info("HACS is enabled") + + def enable_hacs_category(self, category: HacsCategory): + """Enable HACS category.""" + if category not in self.common.categories: + self.log.info("Enable category: %s", category) + self.common.categories.add(category) + + def disable_hacs_category(self, category: HacsCategory): + """Disable HACS category.""" + if category in self.common.categories: + self.log.info("Disabling category: %s", category) + self.common.categories.pop(category) + + async def async_can_update(self) -> int: + """Helper to calculate the number of repositories we can fetch data for.""" + try: + response = await self.githubapi.rate_limit() + if ((limit := response.data.resources.core.remaining or 0) - 1000) >= 15: + return math.floor((limit - 1000) / 15) + self.log.error( + "GitHub API ratelimited - %s remaining", response.data.resources.core.remaining + ) + self.disable_hacs(HacsDisabledReason.RATE_LIMIT) + except GitHubAuthenticationException as exception: + self.log.error("GitHub authentication failed - %s", exception) + self.disable_hacs(HacsDisabledReason.INVALID_TOKEN) + except GitHubRatelimitException as exception: + self.log.error("GitHub API ratelimited - %s", exception) + self.disable_hacs(HacsDisabledReason.RATE_LIMIT) + except BaseException as exception: # pylint: disable=broad-except + self.log.exception(exception) + + return 0 + + async def async_github_get_hacs_default_file(self, filename: str) -> dict[str, Any]: + """Get the content of a default file.""" + response = await self.githubapi.repos.contents.get( + repository=REPOSITORY_HACS_DEFAULT, path=filename + ) + return json.loads(decode_content(response.data.content)) diff --git a/custom_components/hacs/config_flow.py b/custom_components/hacs/config_flow.py index dc25d273..d4930777 100644 --- a/custom_components/hacs/config_flow.py +++ b/custom_components/hacs/config_flow.py @@ -1,6 +1,5 @@ """Adds config flow for HACS.""" -import voluptuous as vol -from aiogithubapi import AIOGitHubAPIException, GitHubDevice +from aiogithubapi import GitHubDeviceAPI, GitHubException from aiogithubapi.common.const import OAUTH_USER_LOGIN from awesomeversion import AwesomeVersion from homeassistant import config_entries @@ -8,20 +7,19 @@ from homeassistant.core import callback from homeassistant.helpers import aiohttp_client from homeassistant.helpers.event import async_call_later +from homeassistant.loader import async_get_integration +import voluptuous as vol from custom_components.hacs.const import CLIENT_ID, DOMAIN, MINIMUM_HA_VERSION +from custom_components.hacs.enums import ConfigurationType from custom_components.hacs.helpers.functions.configuration_schema import ( + RELEASE_LIMIT, hacs_config_option_schema, ) -from custom_components.hacs.helpers.functions.logger import getLogger -from custom_components.hacs.share import get_hacs - -from .base import HacsBase +from custom_components.hacs.mixin import HacsMixin -_LOGGER = getLogger() - -class HacsFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): +class HacsFlowHandler(HacsMixin, config_entries.ConfigFlow, domain=DOMAIN): """Config flow for HACS.""" VERSION = 1 @@ -33,6 +31,7 @@ def __init__(self): self.device = None self.activation = None self._progress_task = None + self._login_device = None async def async_step_user(self, user_input): """Handle a flow initialized by the user.""" @@ -56,30 +55,38 @@ async def async_step_device(self, _user_input): """Handle device steps""" async def _wait_for_activation(_=None): - self.activation = await self.device.async_device_activation() + if self._login_device is None or self._login_device.expires_in is None: + async_call_later(self.hass, 1, _wait_for_activation) + return + + response = await self.device.activation(device_code=self._login_device.device_code) + self.activation = response.data self.hass.async_create_task( self.hass.config_entries.flow.async_configure(flow_id=self.flow_id) ) if not self.activation: + integration = await async_get_integration(self.hass, DOMAIN) if not self.device: - self.device = GitHubDevice( - CLIENT_ID, + 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: - device_data = await self.device.async_register_device() + 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": device_data.user_code, + "code": self._login_device.user_code, }, ) - except AIOGitHubAPIException as exception: - _LOGGER.error(exception) + except GitHubException as exception: + self.hacs.log.error(exception) return self.async_abort(reason="github") return self.async_show_progress_done(next_step_id="device_done") @@ -97,18 +104,12 @@ async def _show_config_form(self, user_input): step_id="user", data_schema=vol.Schema( { - vol.Required( - "acc_logs", default=user_input.get("acc_logs", False) - ): bool, - vol.Required( - "acc_addons", default=user_input.get("acc_addons", False) - ): bool, + vol.Required("acc_logs", default=user_input.get("acc_logs", False)): bool, + vol.Required("acc_addons", default=user_input.get("acc_addons", False)): bool, vol.Required( "acc_untested", default=user_input.get("acc_untested", False) ): bool, - vol.Required( - "acc_disable", default=user_input.get("acc_disable", False) - ): bool, + vol.Required("acc_disable", default=user_input.get("acc_disable", False)): bool, } ), errors=self._errors, @@ -116,9 +117,7 @@ async def _show_config_form(self, user_input): async def async_step_device_done(self, _user_input): """Handle device steps""" - return self.async_create_entry( - title="", data={"token": self.activation.access_token} - ) + return self.async_create_entry(title="", data={"token": self.activation.access_token}) @staticmethod @callback @@ -126,7 +125,7 @@ def async_get_options_flow(config_entry): return HacsOptionsFlowHandler(config_entry) -class HacsOptionsFlowHandler(config_entries.OptionsFlow): +class HacsOptionsFlowHandler(HacsMixin, config_entries.OptionsFlow): """HACS config flow options handler.""" def __init__(self, config_entry): @@ -139,14 +138,16 @@ async def async_step_init(self, _user_input=None): async def async_step_user(self, user_input=None): """Handle a flow initialized by the user.""" - hacs: HacsBase = get_hacs() if user_input is not None: + limit = int(user_input.get(RELEASE_LIMIT, 5)) + if limit <= 0 or limit > 100: + return self.async_abort(reason="release_limit_value") return self.async_create_entry(title="", data=user_input) - if hacs.configuration is None: + if self.hacs.configuration is None: return self.async_abort(reason="not_setup") - if hacs.configuration.config_type == "yaml": + if self.hacs.configuration.config_type == ConfigurationType.YAML: schema = {vol.Optional("not_in_use", default=""): str} else: schema = hacs_config_option_schema(self.config_entry.options) diff --git a/custom_components/hacs/const.py b/custom_components/hacs/const.py index 815dd7b3..5befadeb 100644 --- a/custom_components/hacs/const.py +++ b/custom_components/hacs/const.py @@ -3,27 +3,21 @@ NAME_LONG = "HACS (Home Assistant Community Store)" NAME_SHORT = "HACS" -INTEGRATION_VERSION = "1.12.3" DOMAIN = "hacs" CLIENT_ID = "395a8e669c5de9f7c6e8" -MINIMUM_HA_VERSION = "2020.12.0" +MINIMUM_HA_VERSION = "2021.2.0" PROJECT_URL = "https://github.com/hacs/integration/" -CUSTOM_UPDATER_LOCATIONS = [ - "{}/custom_components/custom_updater.py", - "{}/custom_components/custom_updater/__init__.py", -] + ISSUE_URL = f"{PROJECT_URL}issues" DOMAIN_DATA = f"{NAME_SHORT.lower()}_data" -ELEMENT_TYPES = ["integration", "plugin"] - PACKAGE_NAME = "custom_components.hacs" -HACS_GITHUB_API_HEADERS = { - "User-Agent": f"HACS/{INTEGRATION_VERSION}", - "Accept": ACCEPT_HEADERS["preview"], -} +REPOSITORY_HACS_DEFAULT = "hacs/default" +REPOSITORY_HACS_INTEGRATION = "hacs/integration" + +PLATFORMS = ["sensor"] HACS_ACTION_GITHUB_API_HEADERS = { "User-Agent": "HACS/action", @@ -44,16 +38,11 @@ # Messages NO_ELEMENTS = "No elements to show, open the store to install some awesome stuff." -CUSTOM_UPDATER_WARNING = """ -This cannot be used with custom_updater. -To use this you need to remove custom_updater form {} -""" - -STARTUP = f""" +STARTUP = """ ------------------------------------------------------------------- HACS (Home Assistant Community Store) -Version: {INTEGRATION_VERSION} +Version: {version} This is a custom integration If you have any issues with this you need to open an issue here: https://github.com/hacs/integration/issues diff --git a/custom_components/hacs/enums.py b/custom_components/hacs/enums.py index 5509bf7d..268cf5e3 100644 --- a/custom_components/hacs/enums.py +++ b/custom_components/hacs/enums.py @@ -13,12 +13,21 @@ class HacsCategory(str, Enum): THEME = "theme" REMOVED = "removed" + def __str__(self): + return str(self.value) + + +class ConfigurationType(str, Enum): + YAML = "yaml" + CONFIG_ENTRY = "config_entry" + class LovelaceMode(str, Enum): """Lovelace Modes.""" STORAGE = "storage" AUTO = "auto" + AUTO_GEN = "auto-gen" YAML = "yaml" diff --git a/custom_components/hacs/helpers/classes/exceptions.py b/custom_components/hacs/exceptions.py similarity index 71% rename from custom_components/hacs/helpers/classes/exceptions.py rename to custom_components/hacs/exceptions.py index 4532ee89..f867d3ee 100644 --- a/custom_components/hacs/helpers/classes/exceptions.py +++ b/custom_components/hacs/exceptions.py @@ -1,4 +1,4 @@ -"""Custom Exceptions.""" +"""Custom Exceptions for HACS.""" class HacsException(Exception): @@ -15,3 +15,7 @@ class HacsNotModifiedException(HacsException): class HacsExpectedException(HacsException): """For stuff that are expected.""" + + +class HacsRepositoryExistException(HacsException): + """For repositories that are already exist.""" diff --git a/custom_components/hacs/hacsbase/configuration.py b/custom_components/hacs/hacsbase/configuration.py deleted file mode 100644 index 825582bb..00000000 --- a/custom_components/hacs/hacsbase/configuration.py +++ /dev/null @@ -1,77 +0,0 @@ -"""HACS Configuration.""" -import attr - -from custom_components.hacs.helpers.classes.exceptions import HacsException -from custom_components.hacs.helpers.functions.logger import getLogger - -_LOGGER = getLogger() - - -@attr.s(auto_attribs=True) -class Configuration: - """Configuration class.""" - - # Main configuration: - appdaemon_path: str = "appdaemon/apps/" - appdaemon: bool = False - netdaemon_path: str = "netdaemon/apps/" - netdaemon: bool = False - config: dict = {} - config_entry: dict = {} - config_type: str = None - debug: bool = False - dev: bool = False - frontend_mode: str = "Grid" - frontend_compact: bool = False - frontend_repo: str = "" - frontend_repo_url: str = "" - options: dict = {} - onboarding_done: bool = False - plugin_path: str = "www/community/" - python_script_path: str = "python_scripts/" - python_script: bool = False - sidepanel_icon: str = "hacs:hacs" - sidepanel_title: str = "HACS" - theme_path: str = "themes/" - theme: bool = False - token: str = None - - # Config options: - country: str = "ALL" - experimental: bool = False - release_limit: int = 5 - - def to_json(self) -> dict: - """Return a dict representation of the configuration.""" - return self.__dict__ - - def print(self) -> None: - """Print the current configuration to the log.""" - config = self.to_json() - for key in config: - if key in ["config", "config_entry", "options", "token"]: - continue - _LOGGER.debug("%s: %s", key, config[key]) - - @staticmethod - def from_dict(configuration: dict, options: dict = None) -> None: - """Set attributes from dicts.""" - if isinstance(options, bool) or isinstance(configuration.get("options"), bool): - raise HacsException("Configuration is not valid.") - - if options is None: - options = {} - - if not configuration: - raise HacsException("Configuration is not valid.") - - config = Configuration() - - config.config = configuration - config.options = options - - for conf_type in [configuration, options]: - for key in conf_type: - setattr(config, key, conf_type[key]) - - return config diff --git a/custom_components/hacs/hacsbase/data.py b/custom_components/hacs/hacsbase/data.py index d05bbb4e..f8cb4b58 100644 --- a/custom_components/hacs/hacsbase/data.py +++ b/custom_components/hacs/hacsbase/data.py @@ -1,20 +1,32 @@ """Data handler for HACS.""" +import asyncio import os -from queueman import QueueManager +from homeassistant.core import callback -from custom_components.hacs.const import INTEGRATION_VERSION from custom_components.hacs.helpers.classes.manifest import HacsManifest -from custom_components.hacs.helpers.functions.logger import getLogger from custom_components.hacs.helpers.functions.register_repository import ( register_repository, ) from custom_components.hacs.helpers.functions.store import ( async_load_from_store, async_save_to_store, + async_save_to_store_default_encoder, get_store_for_key, ) from custom_components.hacs.share import get_hacs +from custom_components.hacs.utils.logger import getLogger + + +def update_repository_from_storage(repository, storage_data): + """Merge in data from storage into the repo data.""" + repository.data.memorize_storage(storage_data) + repository.data.update_data(storage_data) + if repository.data.installed: + return + + repository.logger.debug("%s Should be installed but is not... Fixing that!", repository) + repository.data.installed = True class HacsData: @@ -24,7 +36,6 @@ def __init__(self): """Initialize.""" self.logger = getLogger() self.hacs = get_hacs() - self.queue = QueueManager() self.content = {} async def async_write(self): @@ -42,23 +53,23 @@ async def async_write(self): "view": self.hacs.configuration.frontend_mode, "compact": self.hacs.configuration.frontend_compact, "onboarding_done": self.hacs.configuration.onboarding_done, + "archived_repositories": self.hacs.common.archived_repositories, + "renamed_repositories": self.hacs.common.renamed_repositories, }, ) + await self._async_store_content_and_repos() + for event in ("hacs/repository", "hacs/config"): + self.hacs.hass.bus.async_fire(event, {}) + async def _async_store_content_and_repos(self): # bb: ignore + """Store the main repos file and each repo that is out of date.""" # Repositories self.content = {} - for repository in self.hacs.repositories or []: - self.queue.add(self.async_store_repository_data(repository)) - - if not self.queue.has_pending_tasks: - self.logger.debug("Nothing in the queue") - elif self.queue.running: - self.logger.debug("Queue is already running") - else: - await self.queue.execute() + # Not run concurrently since this is bound by disk I/O + for repository in self.hacs.repositories: + await self.async_store_repository_data(repository) + await async_save_to_store(self.hacs.hass, "repositories", self.content) - self.hacs.hass.bus.async_fire("hacs/repository", {}) - self.hacs.hass.bus.async_fire("hacs/config", {}) async def async_store_repository_data(self, repository): repository_manifest = repository.repository_manifest.manifest @@ -85,89 +96,88 @@ async def async_store_repository_data(self, repository): "topics": repository.data.topics, "version_installed": repository.data.installed_version, } - if data: - if repository.data.installed and ( - repository.data.installed_commit or repository.data.installed_version - ): - await async_save_to_store( - self.hacs.hass, - f"hacs/{repository.data.id}.hacs", - repository.data.to_json(), - ) - self.content[str(repository.data.id)] = data + self.content[str(repository.data.id)] = data + + if ( + repository.data.installed + and (repository.data.installed_commit or repository.data.installed_version) + and (export := repository.data.export_data()) + ): + # export_data will return `None` if the memorized + # data is already up to date which allows us to avoid + # writing data that is already up to date or generating + # executor jobs to check the data on disk to see + # if a write is needed. + await async_save_to_store_default_encoder( + self.hacs.hass, + f"hacs/{repository.data.id}.hacs", + export, + ) + repository.data.memorize_storage(export) async def restore(self): """Restore saved data.""" hacs = await async_load_from_store(self.hacs.hass, "hacs") - repositories = await async_load_from_store(self.hacs.hass, "repositories") - try: - if not hacs and not repositories: - # Assume new install - self.hacs.status.new = True - return True - self.logger.info("Restore started") - self.hacs.status.new = False - - # Hacs - self.hacs.configuration.frontend_mode = hacs.get("view", "Grid") - self.hacs.configuration.frontend_compact = hacs.get("compact", False) - self.hacs.configuration.onboarding_done = hacs.get("onboarding_done", False) + repositories = await async_load_from_store(self.hacs.hass, "repositories") or {} - # Repositories - stores = {} - for entry in repositories or []: - stores[entry] = get_store_for_key(self.hacs.hass, f"hacs/{entry}.hacs") + if not hacs and not repositories: + # Assume new install + self.hacs.status.new = True + return True + self.logger.info("Restore started") + self.hacs.status.new = False - stores_exist = {} + # Hacs + self.hacs.configuration.frontend_mode = hacs.get("view", "Grid") + self.hacs.configuration.frontend_compact = hacs.get("compact", False) + self.hacs.configuration.onboarding_done = hacs.get("onboarding_done", False) + self.hacs.common.archived_repositories = hacs.get("archived_repositories", []) + self.hacs.common.renamed_repositories = hacs.get("renamed_repositories", {}) - def _populate_stores(): - for entry in repositories or []: - stores_exist[entry] = os.path.exists(stores[entry].path) + # Repositories + hass = self.hacs.hass + stores = {} - await self.hacs.hass.async_add_executor_job(_populate_stores) + try: + await self.register_unknown_repositories(repositories) - # Repositories - for entry in repositories or []: - self.queue.add( - self.async_restore_repository( - entry, repositories[entry], stores[entry], stores_exist[entry] - ) - ) + for entry, repo_data in repositories.items(): + if self.async_restore_repository(entry, repo_data): + stores[entry] = get_store_for_key(hass, f"hacs/{entry}.hacs") - await self.queue.execute() + def _load_from_storage(): + for entry, store in stores.items(): + if os.path.exists(store.path) and (data := store.load()): + update_repository_from_storage(self.hacs.get_by_id(entry), data) + await hass.async_add_executor_job(_load_from_storage) self.logger.info("Restore done") except (Exception, BaseException) as exception: # pylint: disable=broad-except - self.logger.critical(f"[{exception}] Restore Failed!") + self.logger.critical(f"[{exception}] Restore Failed!", exc_info=exception) return False return True - async def async_restore_repository( - self, entry, repository_data, store, store_exists - ): - if not self.hacs.is_known(entry): - await register_repository( - repository_data["full_name"], repository_data["category"], False - ) - repository = [ - x - for x in self.hacs.repositories - if str(x.data.id) == str(entry) - or x.data.full_name == repository_data["full_name"] + async def register_unknown_repositories(self, repositories): + """Registry any unknown repositories.""" + register_tasks = [ + register_repository(repo_data["full_name"], repo_data["category"], False) + for entry, repo_data in repositories.items() + if not self.hacs.is_known(entry) ] - if not repository: - self.logger.error(f"Did not find {repository_data['full_name']} ({entry})") - return - - repository = repository[0] - + if register_tasks: + await asyncio.gather(*register_tasks) + + @callback + def async_restore_repository(self, entry, repository_data): + full_name = repository_data["full_name"] + if not (repository := self.hacs.get_by_name(full_name)): + self.logger.error(f"Did not find {full_name} ({entry})") + return False # Restore repository attributes - repository.data.id = entry + self.hacs.async_set_repository_id(repository, entry) repository.data.authors = repository_data.get("authors", []) repository.data.description = repository_data.get("description") - repository.releases.last_release_object_downloads = repository_data.get( - "downloads" - ) + repository.releases.last_release_object_downloads = repository_data.get("downloads") repository.data.last_updated = repository_data.get("last_updated") repository.data.etag_repository = repository_data.get("etag_repository") repository.data.topics = repository_data.get("topics", []) @@ -192,15 +202,7 @@ async def async_restore_repository( repository.status.first_install = False if repository_data["full_name"] == "hacs/integration": - repository.data.installed_version = INTEGRATION_VERSION + repository.data.installed_version = self.hacs.version repository.data.installed = True - restored = store_exists and await store.async_load() or {} - - if restored: - repository.data.update_data(restored) - if not repository.data.installed: - repository.logger.debug( - "Should be installed but is not... Fixing that!" - ) - repository.data.installed = True + return True diff --git a/custom_components/hacs/hacsbase/hacs.py b/custom_components/hacs/hacsbase/hacs.py index 1931d04a..d477b712 100644 --- a/custom_components/hacs/hacsbase/hacs.py +++ b/custom_components/hacs/hacsbase/hacs.py @@ -1,8 +1,8 @@ """Initialize the HACS base.""" -import json from datetime import timedelta -from aiogithubapi import AIOGitHubAPIException +from aiogithubapi import GitHubException +from aiogithubapi.exceptions import GitHubNotModifiedException from queueman import QueueManager from queueman.exceptions import QueueManagerExecutionStillInProgress @@ -13,19 +13,11 @@ from custom_components.hacs.helpers.functions.register_repository import ( register_repository, ) -from custom_components.hacs.helpers.functions.remaining_github_calls import ( - get_fetch_updates_for, -) from custom_components.hacs.helpers.functions.store import ( async_load_from_store, async_save_to_store, ) -from custom_components.hacs.operational.setup_actions.categories import ( - async_setup_extra_stores, -) from custom_components.hacs.share import ( - get_factory, - get_queue, get_removed, is_removed, list_removed_repositories, @@ -33,87 +25,74 @@ from ..base import HacsBase from ..enums import HacsCategory, HacsStage - - -class HacsStatus: - """HacsStatus.""" - - startup = True - new = False - background_task = False - reloading_data = False - upgrading_all = False - - -class HacsFrontend: - """HacsFrontend.""" - - version_running = None - version_available = None - version_expected = None - update_pending = False - - -class HacsCommon: - """Common for HACS.""" - - categories = [] - default = [] - installed = [] - skip = [] - - -class System: - """System info.""" - - status = HacsStatus() - config_path = None - ha_version = None - disabled = False - running = False - lovelace_mode = "storage" +from ..share import get_factory, get_queue class Hacs(HacsBase, HacsHelpers): """The base class of HACS, nested throughout the project.""" - repositories = [] - repo = None - data_repo = None - data = None - status = HacsStatus() - configuration = None - version = None - session = None factory = get_factory() queue = get_queue() - recuring_tasks = [] - common = HacsCommon() + + @property + def repositories(self): + """Return the full repositories list.""" + return self._repositories + + def async_set_repositories(self, repositories): + """Set the list of repositories.""" + self._repositories = [] + self._repositories_by_id = {} + self._repositories_by_full_name = {} + + for repository in repositories: + self.async_add_repository(repository) + + def async_set_repository_id(self, repository, repo_id): + """Update a repository id.""" + existing_repo_id = str(repository.data.id) + if existing_repo_id == repo_id: + return + if existing_repo_id != "0": + raise ValueError( + f"The repo id for {repository.data.full_name_lower} is already set to {existing_repo_id}" + ) + repository.data.id = repo_id + self._repositories_by_id[repo_id] = repository + + def async_add_repository(self, repository): + """Add a repository to the list.""" + if repository.data.full_name_lower in self._repositories_by_full_name: + raise ValueError(f"The repo {repository.data.full_name_lower} is already added") + self._repositories.append(repository) + repo_id = str(repository.data.id) + if repo_id != "0": + self._repositories_by_id[repo_id] = repository + self._repositories_by_full_name[repository.data.full_name_lower] = repository + + def async_remove_repository(self, repository): + """Remove a repository from the list.""" + if repository.data.full_name_lower not in self._repositories_by_full_name: + return + self._repositories.remove(repository) + repo_id = str(repository.data.id) + if repo_id in self._repositories_by_id: + del self._repositories_by_id[repo_id] + del self._repositories_by_full_name[repository.data.full_name_lower] def get_by_id(self, repository_id): """Get repository by ID.""" - try: - for repository in self.repositories: - if str(repository.data.id) == str(repository_id): - return repository - except (Exception, BaseException): # pylint: disable=broad-except - pass - return None + return self._repositories_by_id.get(str(repository_id)) def get_by_name(self, repository_full_name): """Get repository by full_name.""" - try: - repository_full_name_lower = repository_full_name.lower() - for repository in self.repositories: - if repository.data.full_name_lower == repository_full_name_lower: - return repository - except (Exception, BaseException): # pylint: disable=broad-except - pass - return None + if repository_full_name is None: + return None + return self._repositories_by_full_name.get(repository_full_name.lower()) def is_known(self, repository_id): """Return a bool if the repository is known.""" - return str(repository_id) in [str(x.data.id) for x in self.repositories] + return str(repository_id) in self._repositories_by_id @property def sorted_by_name(self): @@ -133,7 +112,6 @@ async def startup_tasks(self, _event=None): """Tasks that are started after startup.""" await self.async_set_stage(HacsStage.STARTUP) self.status.background_task = True - await async_setup_extra_stores() self.hass.bus.async_fire("hacs/status", {}) await self.handle_critical_repositories_startup() @@ -191,9 +169,10 @@ async def handle_critical_repositories(self): was_installed = False try: - critical = await self.data_repo.get_contents("critical") - critical = json.loads(critical.content) - except AIOGitHubAPIException: + critical = await self.async_github_get_hacs_default_file("critical") + except GitHubNotModifiedException: + return + except GitHubException: pass if not critical: @@ -253,15 +232,13 @@ async def prosess_queue(self, _notarealarg=None): self.log.debug("Queue is already running") return - can_update = await get_fetch_updates_for(self.github) + can_update = await self.async_can_update() self.log.debug( "Can update %s repositories, items in queue %s", can_update, self.queue.pending_tasks, ) - if can_update == 0: - self.log.info("HACS is ratelimited, repository updates will resume later.") - else: + if can_update != 0: self.status.background_task = True self.hass.bus.async_fire("hacs/status", {}) try: @@ -280,10 +257,7 @@ async def recurring_tasks_installed(self, _notarealarg=None): for repository in self.repositories: if self.status.startup and repository.data.full_name == "hacs/integration": continue - if ( - repository.data.installed - and repository.data.category in self.common.categories - ): + if repository.data.installed and repository.data.category in self.common.categories: self.queue.add(self.factory.safe_update(repository)) await self.handle_critical_repositories() @@ -295,7 +269,6 @@ async def recurring_tasks_installed(self, _notarealarg=None): async def recurring_tasks_all(self, _notarealarg=None): """Recurring tasks for all repositories.""" self.log.debug("Starting recurring background task for all repositories") - await async_setup_extra_stores() self.status.background_task = True self.hass.bus.async_fire("hacs/status", {}) @@ -349,8 +322,12 @@ async def async_get_category_repositories(self, category: HacsCategory): """Get repositories from category.""" repositories = await async_get_list_from_default(category) for repo in repositories: + if self.common.renamed_repositories.get(repo): + repo = self.common.renamed_repositories[repo] if is_removed(repo): continue + if repo in self.common.archived_repositories: + continue repository = self.get_by_name(repo) if repository is not None: if str(repository.data.id) not in self.common.default: @@ -359,9 +336,3 @@ async def async_get_category_repositories(self, category: HacsCategory): continue continue self.queue.add(self.factory.safe_register(repo, category)) - - async def async_set_stage(self, stage: str) -> None: - """Set the stage of HACS.""" - self.stage = HacsStage(stage) - self.log.info("Stage changed: %s", self.stage) - self.hass.bus.async_fire("hacs/stage", {"stage": self.stage}) diff --git a/custom_components/hacs/helpers/classes/manifest.py b/custom_components/hacs/helpers/classes/manifest.py index f3030100..c0e43b9b 100644 --- a/custom_components/hacs/helpers/classes/manifest.py +++ b/custom_components/hacs/helpers/classes/manifest.py @@ -7,7 +7,7 @@ import attr -from custom_components.hacs.helpers.classes.exceptions import HacsException +from custom_components.hacs.exceptions import HacsException @attr.s(auto_attribs=True) @@ -38,6 +38,10 @@ def from_dict(manifest: dict): manifest_data.manifest = manifest + if country := manifest.get("country"): + if isinstance(country, str): + manifest["country"] = [country] + for key in manifest: setattr(manifest_data, key, manifest[key]) return manifest_data diff --git a/custom_components/hacs/helpers/classes/repository.py b/custom_components/hacs/helpers/classes/repository.py index 0ff89bdf..555cb70b 100644 --- a/custom_components/hacs/helpers/classes/repository.py +++ b/custom_components/hacs/helpers/classes/repository.py @@ -2,18 +2,15 @@ # pylint: disable=broad-except, no-member import json import os +import shutil import tempfile import zipfile -import shutil from aiogithubapi import AIOGitHubAPIException from queueman import QueueManager +from custom_components.hacs.exceptions import HacsException, HacsNotModifiedException from custom_components.hacs.helpers import RepositoryHelpers -from custom_components.hacs.helpers.classes.exceptions import ( - HacsException, - HacsNotModifiedException, -) from custom_components.hacs.helpers.classes.manifest import HacsManifest from custom_components.hacs.helpers.classes.repositorydata import RepositoryData from custom_components.hacs.helpers.classes.validate import Validate @@ -23,7 +20,6 @@ get_repository, ) from custom_components.hacs.helpers.functions.is_safe_to_remove import is_safe_to_remove -from custom_components.hacs.helpers.functions.logger import getLogger from custom_components.hacs.helpers.functions.misc import get_repository_name from custom_components.hacs.helpers.functions.save import async_save_file from custom_components.hacs.helpers.functions.store import async_remove_store @@ -35,6 +31,7 @@ version_to_install, ) from custom_components.hacs.share import get_hacs +from custom_components.hacs.utils.logger import getLogger class RepositoryVersions: @@ -144,6 +141,19 @@ def display_name(self): """Return display name.""" return get_repository_name(self) + @property + def ignored_by_country_configuration(self) -> bool: + """Return True if hidden by country.""" + if self.data.installed: + return False + configuration = self.hacs.configuration.country.lower() + manifest = [entry.lower() for entry in self.repository_manifest.country or []] + if configuration == "all": + return False + if not manifest: + return False + return configuration not in manifest + @property def display_status(self): """Return display_status.""" @@ -259,14 +269,8 @@ async def common_update(self, ignore_issues=False, force=False): # Attach repository current_etag = self.data.etag_repository await common_update_data(self, ignore_issues, force) - if ( - not self.data.installed - and (current_etag == self.data.etag_repository) - and not force - ): - self.logger.debug( - "Did not update %s, content was not modified", self.data.full_name - ) + if not self.data.installed and (current_etag == self.data.etag_repository) and not force: + self.logger.debug("Did not update %s, content was not modified", self.data.full_name) return False # Update last updated @@ -291,9 +295,7 @@ async def download_zip_files(self, validate): contents = False for release in self.releases.objects: - self.logger.info( - "%s ref: %s --- tag: %s.", self, self.ref, release.tag_name - ) + self.logger.info("%s ref: %s --- tag: %s.", self, self.ref, release.tag_name) if release.tag_name == self.ref.split("/")[1]: contents = release.assets @@ -353,9 +355,7 @@ async def get_repository_manifest_content(self): """Get the content of the hacs.json file.""" if not "hacs.json" in [x.filename for x in self.tree]: if self.hacs.system.action: - raise HacsException( - "::error:: No hacs.json file in the root of the repository." - ) + raise HacsException("::error:: No hacs.json file in the root of the repository.") return if self.hacs.system.action: self.logger.info("%s Found hacs.json", self) @@ -364,9 +364,7 @@ async def get_repository_manifest_content(self): try: manifest = await self.repository_object.get_contents("hacs.json", self.ref) - self.repository_manifest = HacsManifest.from_dict( - json.loads(manifest.content) - ) + self.repository_manifest = HacsManifest.from_dict(json.loads(manifest.content)) self.data.update_data(json.loads(manifest.content)) except (AIOGitHubAPIException, Exception) as exception: # Gotta Catch 'Em All if self.hacs.system.action: @@ -384,7 +382,7 @@ def remove(self): self.hacs.common.installed.remove(self.data.id) for repository in self.hacs.repositories: if repository.data.id == self.data.id: - self.hacs.repositories.remove(repository) + self.hacs.async_remove_repository(repository) async def uninstall(self): """Run uninstall tasks.""" @@ -399,9 +397,7 @@ async def uninstall(self): self.pending_restart = True elif self.data.category == "theme": try: - await self.hacs.hass.services.async_call( - "frontend", "reload_themes", {} - ) + await self.hacs.hass.services.async_call("frontend", "reload_themes", {}) except (Exception, BaseException): # pylint: disable=broad-except pass if self.data.full_name in self.hacs.common.installed: @@ -418,7 +414,6 @@ async def uninstall(self): async def remove_local_directory(self): """Check the local directory.""" - import shutil from asyncio import sleep try: @@ -442,9 +437,7 @@ async def remove_local_directory(self): if os.path.exists(local_path): if not is_safe_to_remove(local_path): - self.logger.error( - "%s Path %s is blocked from removal", self, local_path - ) + self.logger.error("%s Path %s is blocked from removal", self, local_path) return False self.logger.debug("%s Removing %s", self, local_path) @@ -461,8 +454,6 @@ async def remove_local_directory(self): ) except (Exception, BaseException) as exception: - self.logger.debug( - "%s Removing %s failed with %s", self, local_path, exception - ) + self.logger.debug("%s Removing %s failed with %s", self, local_path, exception) return False return True diff --git a/custom_components/hacs/helpers/classes/repositorydata.py b/custom_components/hacs/helpers/classes/repositorydata.py index ce8870ac..6011346c 100644 --- a/custom_components/hacs/helpers/classes/repositorydata.py +++ b/custom_components/hacs/helpers/classes/repositorydata.py @@ -1,8 +1,10 @@ """Repository data.""" from datetime import datetime -from typing import List +import json +from typing import List, Optional import attr +from homeassistant.helpers.json import JSONEncoder @attr.s(auto_attribs=True) @@ -51,6 +53,7 @@ class RepositoryData: stargazers_count: int = 0 topics: List[str] = [] zip_release: bool = False + _storage_data: Optional[dict] = None @property def stars(self): @@ -66,64 +69,78 @@ def name(self): def to_json(self): """Export to json.""" - return attr.asdict(self) + return attr.asdict(self, filter=lambda attr, _: attr.name != "_storage_data") + + def memorize_storage(self, data) -> None: + """Memorize the storage data.""" + self._storage_data = data + + def export_data(self) -> Optional[dict]: + """Export to json if the data has changed. + + Returns the data to export if the data needs + to be written. + + Returns None if the data has not changed. + """ + export = json.loads(json.dumps(self.to_json(), cls=JSONEncoder)) + return None if self._storage_data == export else export @staticmethod def create_from_dict(source: dict): """Set attributes from dicts.""" data = RepositoryData() for key in source: - print(key) - if key in data.__dict__: - if key == "pushed_at": - if source[key] == "": - continue - if "Z" in source[key]: - setattr( - data, - key, - datetime.strptime(source[key], "%Y-%m-%dT%H:%M:%SZ"), - ) - else: - setattr( - data, - key, - datetime.strptime(source[key], "%Y-%m-%dT%H:%M:%S"), - ) - elif key == "id": - setattr(data, key, str(source[key])) - elif key == "country": - if isinstance(source[key], str): - setattr(data, key, [source[key]]) - else: - setattr(data, key, source[key]) + if key not in data.__dict__: + continue + if key == "pushed_at": + if source[key] == "": + continue + if "Z" in source[key]: + setattr( + data, + key, + datetime.strptime(source[key], "%Y-%m-%dT%H:%M:%SZ"), + ) + else: + setattr( + data, + key, + datetime.strptime(source[key], "%Y-%m-%dT%H:%M:%S"), + ) + elif key == "id": + setattr(data, key, str(source[key])) + elif key == "country": + if isinstance(source[key], str): + setattr(data, key, [source[key]]) else: setattr(data, key, source[key]) + else: + setattr(data, key, source[key]) return data def update_data(self, data: dict): """Update data of the repository.""" for key in data: - if key in self.__dict__: - if key == "pushed_at": - if data[key] == "": - continue - if "Z" in data[key]: - setattr( - self, - key, - datetime.strptime(data[key], "%Y-%m-%dT%H:%M:%SZ"), - ) - else: - setattr( - self, key, datetime.strptime(data[key], "%Y-%m-%dT%H:%M:%S") - ) - elif key == "id": - setattr(self, key, str(data[key])) - elif key == "country": - if isinstance(data[key], str): - setattr(self, key, [data[key]]) - else: - setattr(self, key, data[key]) + if key not in self.__dict__: + continue + if key == "pushed_at": + if data[key] == "": + continue + if "Z" in data[key]: + setattr( + self, + key, + datetime.strptime(data[key], "%Y-%m-%dT%H:%M:%SZ"), + ) + else: + setattr(self, key, datetime.strptime(data[key], "%Y-%m-%dT%H:%M:%S")) + elif key == "id": + setattr(self, key, str(data[key])) + elif key == "country": + if isinstance(data[key], str): + setattr(self, key, [data[key]]) else: setattr(self, key, data[key]) + else: + setattr(self, key, data[key]) diff --git a/custom_components/hacs/helpers/functions/constrains.py b/custom_components/hacs/helpers/functions/constrains.py deleted file mode 100644 index 93b4e070..00000000 --- a/custom_components/hacs/helpers/functions/constrains.py +++ /dev/null @@ -1,43 +0,0 @@ -"""HACS Startup constrains.""" -# pylint: disable=bad-continuation -import os - -from custom_components.hacs.const import ( - CUSTOM_UPDATER_LOCATIONS, - CUSTOM_UPDATER_WARNING, - MINIMUM_HA_VERSION, -) -from custom_components.hacs.helpers.functions.misc import version_left_higher_then_right -from custom_components.hacs.share import get_hacs - - -def check_constrains(): - """Check HACS constrains.""" - if not constrain_custom_updater(): - return False - if not constrain_version(): - return False - return True - - -def constrain_custom_updater(): - """Check if custom_updater exist.""" - hacs = get_hacs() - for location in CUSTOM_UPDATER_LOCATIONS: - if os.path.exists(location.format(hacs.core.config_path)): - msg = CUSTOM_UPDATER_WARNING.format(location.format(hacs.core.config_path)) - hacs.log.critical(msg) - return False - return True - - -def constrain_version(): - """Check if the version is valid.""" - hacs = get_hacs() - if not version_left_higher_then_right(hacs.system.ha_version, MINIMUM_HA_VERSION): - hacs.log.critical( - "You need HA version %s or newer to use this integration.", - MINIMUM_HA_VERSION, - ) - return False - return True diff --git a/custom_components/hacs/helpers/functions/download.py b/custom_components/hacs/helpers/functions/download.py index 18851bfb..d5901068 100644 --- a/custom_components/hacs/helpers/functions/download.py +++ b/custom_components/hacs/helpers/functions/download.py @@ -8,13 +8,13 @@ import backoff from queueman import QueueManager, concurrent -from custom_components.hacs.helpers.classes.exceptions import HacsException +from custom_components.hacs.exceptions import HacsException from custom_components.hacs.helpers.functions.filters import ( filter_content_return_one_of_type, ) -from custom_components.hacs.helpers.functions.logger import getLogger from custom_components.hacs.helpers.functions.save import async_save_file from custom_components.hacs.share import get_hacs +from custom_components.hacs.utils.logger import getLogger _LOGGER = getLogger() @@ -47,11 +47,7 @@ async def async_download_file(url): if request.status == 200: result = await request.read() else: - raise HacsException( - "Got status code {} when trying to download {}".format( - request.status, url - ) - ) + raise HacsException(f"Got status code {request.status} when trying to download {url}") return result @@ -92,18 +88,14 @@ def gather_files_to_download(repository): for treefile in tree: if treefile.filename == repository.data.file_name: files.append( - FileInformation( - treefile.download_url, treefile.full_path, treefile.filename - ) + FileInformation(treefile.download_url, treefile.full_path, treefile.filename) ) return files if category == "plugin": for treefile in tree: if treefile.path in ["", "dist"]: - if remotelocation == "dist" and not treefile.filename.startswith( - "dist" - ): + if remotelocation == "dist" and not treefile.filename.startswith("dist"): continue if not remotelocation: if not treefile.filename.endswith(".js"): @@ -122,17 +114,13 @@ def gather_files_to_download(repository): if repository.data.content_in_root: if not repository.data.filename: if category == "theme": - tree = filter_content_return_one_of_type( - repository.tree, "", "yaml", "full_path" - ) + tree = filter_content_return_one_of_type(repository.tree, "", "yaml", "full_path") for path in tree: if path.is_directory: continue if path.full_path.startswith(repository.content.path.remote): - files.append( - FileInformation(path.download_url, path.full_path, path.filename) - ) + files.append(FileInformation(path.download_url, path.full_path, path.filename)) return files @@ -142,9 +130,7 @@ async def download_zip_files(repository, validate): queue = QueueManager() try: for release in repository.releases.objects: - repository.logger.info( - f"ref: {repository.ref} --- tag: {release.tag_name}" - ) + repository.logger.info(f"ref: {repository.ref} --- tag: {release.tag_name}") if release.tag_name == repository.ref.split("/")[1]: contents = release.assets @@ -210,37 +196,39 @@ async def download_content(repository): @concurrent(10) async def dowload_repository_content(repository, content): """Download content.""" - repository.logger.debug(f"Downloading {content.name}") + try: + repository.logger.debug(f"Downloading {content.name}") - filecontent = await async_download_file(content.download_url) + filecontent = await async_download_file(content.download_url) - if filecontent is None: - repository.validate.errors.append(f"[{content.name}] was not downloaded.") - return + if filecontent is None: + repository.validate.errors.append(f"[{content.name}] was not downloaded.") + return - # Save the content of the file. - if repository.content.single or content.path is None: - local_directory = repository.content.path.local + # Save the content of the file. + if repository.content.single or content.path is None: + local_directory = repository.content.path.local - else: - _content_path = content.path - if not repository.data.content_in_root: - _content_path = _content_path.replace( - f"{repository.content.path.remote}", "" - ) + else: + _content_path = content.path + if not repository.data.content_in_root: + _content_path = _content_path.replace(f"{repository.content.path.remote}", "") - local_directory = f"{repository.content.path.local}/{_content_path}" - local_directory = local_directory.split("/") - del local_directory[-1] - local_directory = "/".join(local_directory) + local_directory = f"{repository.content.path.local}/{_content_path}" + local_directory = local_directory.split("/") + del local_directory[-1] + local_directory = "/".join(local_directory) - # Check local directory - pathlib.Path(local_directory).mkdir(parents=True, exist_ok=True) + # Check local directory + pathlib.Path(local_directory).mkdir(parents=True, exist_ok=True) - local_file_path = (f"{local_directory}/{content.name}").replace("//", "/") + local_file_path = (f"{local_directory}/{content.name}").replace("//", "/") - result = await async_save_file(local_file_path, filecontent) - if result: - repository.logger.info(f"Download of {content.name} completed") - return - repository.validate.errors.append(f"[{content.name}] was not downloaded.") + result = await async_save_file(local_file_path, filecontent) + if result: + repository.logger.info(f"Download of {content.name} completed") + return + repository.validate.errors.append(f"[{content.name}] was not downloaded.") + + except (Exception, BaseException) as exception: # pylint: disable=broad-except + repository.validate.errors.append(f"Download was not completed [{exception}]") diff --git a/custom_components/hacs/helpers/functions/filters.py b/custom_components/hacs/helpers/functions/filters.py index 4f984911..75055e95 100644 --- a/custom_components/hacs/helpers/functions/filters.py +++ b/custom_components/hacs/helpers/functions/filters.py @@ -1,9 +1,7 @@ """Filter functions.""" -def filter_content_return_one_of_type( - content, namestartswith, filterfiltype, attr="name" -): +def filter_content_return_one_of_type(content, namestartswith, filterfiltype, attr="name"): """Only match 1 of the filter.""" contents = [] filetypefound = False diff --git a/custom_components/hacs/helpers/functions/get_list_from_default.py b/custom_components/hacs/helpers/functions/get_list_from_default.py index 3b4e5ba5..3f9d4185 100644 --- a/custom_components/hacs/helpers/functions/get_list_from_default.py +++ b/custom_components/hacs/helpers/functions/get_list_from_default.py @@ -1,11 +1,14 @@ """Helper to get default repositories.""" -import json from typing import List -from aiogithubapi import AIOGitHubAPIException +from aiogithubapi import ( + GitHubAuthenticationException, + GitHubNotModifiedException, + GitHubRatelimitException, +) -from custom_components.hacs.enums import HacsCategory -from custom_components.hacs.helpers.classes.exceptions import HacsException +from custom_components.hacs.const import REPOSITORY_HACS_DEFAULT +from custom_components.hacs.enums import HacsCategory, HacsDisabledReason from custom_components.hacs.share import get_hacs @@ -15,17 +18,20 @@ async def async_get_list_from_default(default: HacsCategory) -> List: repositories = [] try: - content = await hacs.data_repo.get_contents( - default, hacs.data_repo.default_branch - ) - repositories = json.loads(content.content) + repositories = await hacs.async_github_get_hacs_default_file(default) + hacs.log.debug("Got %s elements for %s", len(repositories), default) + except GitHubNotModifiedException: + hacs.log.debug("Content did not change for %s/%s", REPOSITORY_HACS_DEFAULT, default) - except (AIOGitHubAPIException, HacsException) as exception: + except GitHubRatelimitException as exception: hacs.log.error(exception) + hacs.disable_hacs(HacsDisabledReason.RATE_LIMIT) - except (Exception, BaseException) as exception: + except GitHubAuthenticationException as exception: hacs.log.error(exception) + hacs.disable_hacs(HacsDisabledReason.INVALID_TOKEN) - hacs.log.debug("Got %s elements for %s", len(repositories), default) + except BaseException as exception: # pylint: disable=broad-except + hacs.log.error(exception) return repositories diff --git a/custom_components/hacs/helpers/functions/information.py b/custom_components/hacs/helpers/functions/information.py index 4e9b8415..ba0d6f96 100644 --- a/custom_components/hacs/helpers/functions/information.py +++ b/custom_components/hacs/helpers/functions/information.py @@ -1,15 +1,12 @@ """Return repository information if any.""" import json -from aiogithubapi import AIOGitHubAPIException, GitHub, AIOGitHubAPINotModifiedException +from aiogithubapi import AIOGitHubAPIException, AIOGitHubAPINotModifiedException, GitHub +from aiogithubapi.const import ACCEPT_HEADERS -from custom_components.hacs.helpers.classes.exceptions import ( - HacsException, - HacsNotModifiedException, -) +from custom_components.hacs.exceptions import HacsException, HacsNotModifiedException from custom_components.hacs.helpers.functions.template import render_template from custom_components.hacs.share import get_hacs -from custom_components.hacs.const import HACS_GITHUB_API_HEADERS def info_file(repository): @@ -48,11 +45,15 @@ async def get_info_md_content(repository): async def get_repository(session, token, repository_full_name, etag=None): """Return a repository object or None.""" + hacs = get_hacs() try: github = GitHub( token, session, - headers=HACS_GITHUB_API_HEADERS, + headers={ + "User-Agent": f"HACS/{hacs.version}", + "Accept": ACCEPT_HEADERS["preview"], + }, ) repository = await github.get_repo(repository_full_name, etag) return repository, github.client.last_response.etag @@ -95,9 +96,7 @@ def read_hacs_manifest(): """Reads the HACS manifest file and returns the contents.""" hacs = get_hacs() content = {} - with open( - f"{hacs.core.config_path}/custom_components/hacs/manifest.json" - ) as manifest: + with open(f"{hacs.core.config_path}/custom_components/hacs/manifest.json") as manifest: content = json.loads(manifest.read()) return content @@ -111,9 +110,7 @@ async def get_integration_manifest(repository): if not manifest_path in [x.full_path for x in repository.tree]: raise HacsException(f"No file found '{manifest_path}'") try: - manifest = await repository.repository_object.get_contents( - manifest_path, repository.ref - ) + manifest = await repository.repository_object.get_contents(manifest_path, repository.ref) manifest = json.loads(manifest.content) except (Exception, BaseException) as exception: # pylint: disable=broad-except raise HacsException(f"Could not read manifest.json [{exception}]") @@ -197,9 +194,7 @@ def get_file_name_plugin(repository): else: for filename in valid_filenames: - if f"{location+'/' if location else ''}{filename}" in [ - x.full_path for x in tree - ]: + if f"{location+'/' if location else ''}{filename}" in [x.full_path for x in tree]: repository.data.file_name = filename.split("/")[-1] repository.content.path.remote = location break diff --git a/custom_components/hacs/helpers/functions/is_safe_to_remove.py b/custom_components/hacs/helpers/functions/is_safe_to_remove.py index 4de39662..ca54f18f 100644 --- a/custom_components/hacs/helpers/functions/is_safe_to_remove.py +++ b/custom_components/hacs/helpers/functions/is_safe_to_remove.py @@ -1,20 +1,10 @@ """Helper to check if path is safe to remove.""" -from pathlib import Path - from custom_components.hacs.share import get_hacs +from ...utils.path import is_safe + def is_safe_to_remove(path: str) -> bool: """Helper to check if path is safe to remove.""" hacs = get_hacs() - paths = [ - Path(f"{hacs.core.config_path}/{hacs.configuration.appdaemon_path}"), - Path(f"{hacs.core.config_path}/{hacs.configuration.netdaemon_path}"), - Path(f"{hacs.core.config_path}/{hacs.configuration.plugin_path}"), - Path(f"{hacs.core.config_path}/{hacs.configuration.python_script_path}"), - Path(f"{hacs.core.config_path}/{hacs.configuration.theme_path}"), - Path(f"{hacs.core.config_path}/custom_components/"), - ] - if Path(path) in paths: - return False - return True + return is_safe(hacs, path) diff --git a/custom_components/hacs/helpers/functions/misc.py b/custom_components/hacs/helpers/functions/misc.py index 5114d6b5..e31977b8 100644 --- a/custom_components/hacs/helpers/functions/misc.py +++ b/custom_components/hacs/helpers/functions/misc.py @@ -1,8 +1,7 @@ """Helper functions: misc""" import re -from functools import lru_cache -from awesomeversion import AwesomeVersion +from ...utils import version RE_REPOSITORY = re.compile( r"(?:(?:.*github.com.)|^)([A-Za-z0-9-]+\/[\w.-]+?)(?:(?:\.git)?|(?:[^\w.-].*)?)$" @@ -20,18 +19,12 @@ def get_repository_name(repository) -> str: if "name" in repository.integration_manifest: return repository.integration_manifest["name"] - return ( - repository.data.full_name.split("/")[-1] - .replace("-", " ") - .replace("_", " ") - .title() - ) + return repository.data.full_name.split("/")[-1].replace("-", " ").replace("_", " ").title() -@lru_cache(maxsize=1024) def version_left_higher_then_right(left: str, right: str) -> bool: """Return a bool if source is newer than target, will also be true if identical.""" - return AwesomeVersion(left) >= AwesomeVersion(right) + return version.version_left_higher_then_right(left, right) def extract_repository_from_url(url: str) -> str or None: diff --git a/custom_components/hacs/helpers/functions/register_repository.py b/custom_components/hacs/helpers/functions/register_repository.py index 2fad499a..ef5f2e69 100644 --- a/custom_components/hacs/helpers/functions/register_repository.py +++ b/custom_components/hacs/helpers/functions/register_repository.py @@ -1,14 +1,21 @@ """Register a repository.""" +from __future__ import annotations + +from typing import TYPE_CHECKING + from aiogithubapi import AIOGitHubAPIException -from custom_components.hacs.helpers.classes.exceptions import ( +from custom_components.hacs.exceptions import ( HacsException, HacsExpectedException, + HacsRepositoryExistException, ) from custom_components.hacs.share import get_hacs from ...repositories import RERPOSITORY_CLASSES +if TYPE_CHECKING: + from ..classes.repository import HacsRepository # @concurrent(15, 5) async def register_repository(full_name, category, check=True, ref=None): @@ -22,7 +29,7 @@ async def register_repository(full_name, category, check=True, ref=None): if category not in RERPOSITORY_CLASSES: raise HacsException(f"{category} is not a valid repository category.") - repository = RERPOSITORY_CLASSES[category](full_name) + repository: HacsRepository = RERPOSITORY_CLASSES[category](full_name) if check: try: await repository.async_registration(ref) @@ -39,26 +46,17 @@ async def register_repository(full_name, category, check=True, ref=None): repository.logger.info("%s Validation completed", repository) else: repository.logger.info("%s Registration completed", repository) + except HacsRepositoryExistException: + return except AIOGitHubAPIException as exception: hacs.common.skip.append(repository.data.full_name) - raise HacsException( - f"Validation for {full_name} failed with {exception}." - ) from None - - exists = ( - False - if str(repository.data.id) == "0" - else [x for x in hacs.repositories if str(x.data.id) == str(repository.data.id)] - ) + raise HacsException(f"Validation for {full_name} failed with {exception}.") from None - if exists: - if exists[0] in hacs.repositories: - hacs.repositories.remove(exists[0]) + if str(repository.data.id) != "0" and (exists := hacs.get_by_id(repository.data.id)): + hacs.async_remove_repository(exists) else: - if hacs.hass is not None and ( - (check and repository.data.new) or hacs.status.new - ): + if hacs.hass is not None and ((check and repository.data.new) or hacs.status.new): hacs.hass.bus.async_fire( "hacs/repository", { @@ -67,4 +65,4 @@ async def register_repository(full_name, category, check=True, ref=None): "repository_id": repository.data.id, }, ) - hacs.repositories.append(repository) + hacs.async_add_repository(repository) diff --git a/custom_components/hacs/helpers/functions/remaining_github_calls.py b/custom_components/hacs/helpers/functions/remaining_github_calls.py deleted file mode 100644 index 27a0afe7..00000000 --- a/custom_components/hacs/helpers/functions/remaining_github_calls.py +++ /dev/null @@ -1,32 +0,0 @@ -"""Helper to calculate the remaining calls to github.""" -import math - -from custom_components.hacs.helpers.functions.logger import getLogger - -_LOGGER = getLogger() - - -async def remaining(github): - """Helper to calculate the remaining calls to github.""" - try: - ratelimits = await github.get_rate_limit() - except (BaseException, Exception) as exception: # pylint: disable=broad-except - _LOGGER.error(exception) - return None - if ratelimits.get("remaining") is not None: - return int(ratelimits["remaining"]) - return 0 - - -async def get_fetch_updates_for(github): - """Helper to calculate the number of repositories we can fetch data for.""" - margin = 1000 - limit = await remaining(github) - pr_repo = 15 - - if limit is None: - return None - - if limit - margin <= pr_repo: - return 0 - return math.floor((limit - margin) / pr_repo) diff --git a/custom_components/hacs/helpers/functions/save.py b/custom_components/hacs/helpers/functions/save.py index 05a6e03f..c93a5f60 100644 --- a/custom_components/hacs/helpers/functions/save.py +++ b/custom_components/hacs/helpers/functions/save.py @@ -5,7 +5,7 @@ import aiofiles -from custom_components.hacs.helpers.functions.logger import getLogger +from custom_components.hacs.utils.logger import getLogger _LOGGER = getLogger() @@ -23,9 +23,7 @@ async def async_save_file(location, content): errors = None try: - async with aiofiles.open( - location, mode=mode, encoding=encoding, errors=errors - ) as outfile: + async with aiofiles.open(location, mode=mode, encoding=encoding, errors=errors) as outfile: await outfile.write(content) outfile.close() diff --git a/custom_components/hacs/helpers/functions/store.py b/custom_components/hacs/helpers/functions/store.py index 04c919af..b9e0f335 100644 --- a/custom_components/hacs/helpers/functions/store.py +++ b/custom_components/hacs/helpers/functions/store.py @@ -1,44 +1,79 @@ """Storage handers.""" # pylint: disable=import-outside-toplevel from homeassistant.helpers.json import JSONEncoder +from homeassistant.helpers.storage import Store +from homeassistant.util import json as json_util from custom_components.hacs.const import VERSION_STORAGE -from .logger import getLogger + +from ...utils.logger import getLogger _LOGGER = getLogger() -def get_store_for_key(hass, key): +class HACSStore(Store): + """A subclass of Store that allows multiple loads in the executor.""" + + def load(self): + """Load the data from disk if version matches.""" + data = json_util.load_json(self.path) + if data == {} or data["version"] != self.version: + return None + return data["data"] + + +def get_store_key(key): + """Return the key to use with homeassistant.helpers.storage.Storage.""" + return key if "/" in key else f"hacs.{key}" + + +def _get_store_for_key(hass, key, encoder): """Create a Store object for the key.""" - key = key if "/" in key else f"hacs.{key}" - from homeassistant.helpers.storage import Store + return HACSStore(hass, VERSION_STORAGE, get_store_key(key), encoder=encoder) + - return Store(hass, VERSION_STORAGE, key, encoder=JSONEncoder) +def get_store_for_key(hass, key): + """Create a Store object for the key.""" + return _get_store_for_key(hass, key, JSONEncoder) async def async_load_from_store(hass, key): """Load the retained data from store and return de-serialized data.""" - store = get_store_for_key(hass, key) - restored = await store.async_load() - if restored is None: - return {} - return restored + return await get_store_for_key(hass, key).async_load() or {} + + +async def async_save_to_store_default_encoder(hass, key, data): + """Generate store json safe data to the filesystem. + + The data is expected to be encodable with the default + python json encoder. It should have already been passed through + JSONEncoder if needed. + """ + await _get_store_for_key(hass, key, None).async_save(data) async def async_save_to_store(hass, key, data): - """Generate dynamic data to store and save it to the filesystem.""" + """Generate dynamic data to store and save it to the filesystem. + + The data is only written if the content on the disk has changed + by reading the existing content and comparing it. + + If the data has changed this will generate two executor jobs + + If the data has not changed this will generate one executor job + """ current = await async_load_from_store(hass, key) if current is None or current != data: await get_store_for_key(hass, key).async_save(data) return _LOGGER.debug( "Did not store data for '%s'. Content did not change", - key if "/" in key else f"hacs.{key}", + get_store_key(key), ) async def async_remove_store(hass, key): - """Remove a store element that should no longer be used""" + """Remove a store element that should no longer be used.""" if "/" not in key: return await get_store_for_key(hass, key).async_remove() diff --git a/custom_components/hacs/helpers/functions/template.py b/custom_components/hacs/helpers/functions/template.py index 4f59840f..a7f16edf 100644 --- a/custom_components/hacs/helpers/functions/template.py +++ b/custom_components/hacs/helpers/functions/template.py @@ -2,7 +2,7 @@ # pylint: disable=broad-except from jinja2 import Template -from custom_components.hacs.helpers.functions.logger import getLogger +from custom_components.hacs.utils.logger import getLogger _LOGGER = getLogger() diff --git a/custom_components/hacs/helpers/functions/validate_repository.py b/custom_components/hacs/helpers/functions/validate_repository.py index 7a88d706..815217c6 100644 --- a/custom_components/hacs/helpers/functions/validate_repository.py +++ b/custom_components/hacs/helpers/functions/validate_repository.py @@ -1,10 +1,15 @@ """Helper to do common validation for repositories.""" +from __future__ import annotations + +from typing import TYPE_CHECKING + from aiogithubapi import AIOGitHubAPIException -from custom_components.hacs.helpers.classes.exceptions import ( +from custom_components.hacs.exceptions import ( HacsException, HacsNotModifiedException, HacsRepositoryArchivedException, + HacsRepositoryExistException, ) from custom_components.hacs.helpers.functions.information import ( get_releases, @@ -16,6 +21,9 @@ ) from custom_components.hacs.share import get_hacs, is_removed +if TYPE_CHECKING: + from custom_components.hacs.helpers.classes.repository import HacsRepository + async def common_validate(repository, ignore_issues=False): """Common validation steps of the repository.""" @@ -29,7 +37,7 @@ async def common_validate(repository, ignore_issues=False): await repository.get_repository_manifest_content() -async def common_update_data(repository, ignore_issues=False, force=False): +async def common_update_data(repository: HacsRepository, ignore_issues=False, force=False): """Common update data.""" hacs = get_hacs() releases = [] @@ -38,15 +46,22 @@ async def common_update_data(repository, ignore_issues=False, force=False): hacs.session, hacs.configuration.token, repository.data.full_name, - etag=None - if force or repository.data.installed - else repository.data.etag_repository, + etag=None if force or repository.data.installed else repository.data.etag_repository, ) repository.repository_object = repository_object + if repository.data.full_name.lower() != repository_object.full_name.lower(): + hacs.common.renamed_repositories[ + repository.data.full_name + ] = repository_object.full_name + if str(repository_object.id) not in hacs.common.default: + hacs.common.default.append(str(repository_object.id)) + raise HacsRepositoryExistException repository.data.update_data(repository_object.attributes) repository.data.etag_repository = etag except HacsNotModifiedException: return + except HacsRepositoryExistException: + raise HacsRepositoryExistException from None except (AIOGitHubAPIException, HacsException) as exception: if not hacs.status.startup: repository.logger.error("%s %s", repository, exception) @@ -57,6 +72,7 @@ async def common_update_data(repository, ignore_issues=False, force=False): # Make sure the repository is not archived. if repository.data.archived and not ignore_issues: repository.validate.errors.append("Repository is archived.") + hacs.common.archived_repositories.append(repository.data.full_name) raise HacsRepositoryArchivedException("Repository is archived.") # Make sure the repository is not in the blacklist. @@ -74,9 +90,7 @@ async def common_update_data(repository, ignore_issues=False, force=False): if releases: repository.data.releases = True repository.releases.objects = [x for x in releases if not x.draft] - repository.data.published_tags = [ - x.tag_name for x in repository.releases.objects - ] + repository.data.published_tags = [x.tag_name for x in repository.releases.objects] repository.data.last_version = next(iter(repository.data.published_tags)) except (AIOGitHubAPIException, HacsException): diff --git a/custom_components/hacs/helpers/methods/installation.py b/custom_components/hacs/helpers/methods/installation.py index 92205826..7e152177 100644 --- a/custom_components/hacs/helpers/methods/installation.py +++ b/custom_components/hacs/helpers/methods/installation.py @@ -1,9 +1,9 @@ # pylint: disable=missing-class-docstring,missing-module-docstring,missing-function-docstring,no-member +from abc import ABC import os import tempfile -from abc import ABC -from custom_components.hacs.helpers.classes.exceptions import HacsException +from custom_components.hacs.exceptions import HacsException from custom_components.hacs.helpers.functions.download import download_content from custom_components.hacs.helpers.functions.version_to_install import ( version_to_install, @@ -56,9 +56,7 @@ async def async_install_repository(repository): repository.validate.errors = [] if not repository.can_install: - raise HacsException( - "The version of Home Assistant is not compatible with this version" - ) + raise HacsException("The version of Home Assistant is not compatible with this version") version = version_to_install(repository) if version == repository.data.default_branch: @@ -67,9 +65,7 @@ async def async_install_repository(repository): repository.ref = f"tags/{version}" if repository.data.installed and repository.data.category == "netdaemon": - persistent_directory = await hacs.hass.async_add_executor_job( - BackupNetDaemon, repository - ) + persistent_directory = await hacs.hass.async_add_executor_job(BackupNetDaemon, repository) await hacs.hass.async_add_executor_job(persistent_directory.create) elif repository.data.persistent_directory: @@ -87,7 +83,7 @@ async def async_install_repository(repository): await hacs.hass.async_add_executor_job(backup.create) if repository.data.zip_release and version != repository.data.default_branch: - await repository.download_zip_files(repository) + await repository.download_zip_files(repository.validate) else: await download_content(repository) diff --git a/custom_components/hacs/helpers/methods/registration.py b/custom_components/hacs/helpers/methods/registration.py index d6f22887..71eed45c 100644 --- a/custom_components/hacs/helpers/methods/registration.py +++ b/custom_components/hacs/helpers/methods/registration.py @@ -11,9 +11,7 @@ async def async_pre_registration(self): class RepositoryMethodRegistration(ABC): async def registration(self, ref=None) -> None: - self.logger.warning( - "'registration' is deprecated, use 'async_registration' instead" - ) + self.logger.warning("'registration' is deprecated, use 'async_registration' instead") await self.async_registration(ref) async def async_registration(self, ref=None) -> None: diff --git a/custom_components/hacs/helpers/properties/can_be_installed.py b/custom_components/hacs/helpers/properties/can_be_installed.py index df6b4d64..6ec5df62 100644 --- a/custom_components/hacs/helpers/properties/can_be_installed.py +++ b/custom_components/hacs/helpers/properties/can_be_installed.py @@ -10,7 +10,7 @@ def can_be_installed(self) -> bool: if self.data.homeassistant is not None: if self.data.releases: if not version_left_higher_then_right( - self.hacs.system.ha_version, self.data.homeassistant + self.hacs.core.ha_version, self.data.homeassistant ): return False return True diff --git a/custom_components/hacs/manifest.json b/custom_components/hacs/manifest.json index f7140773..5fe79853 100644 --- a/custom_components/hacs/manifest.json +++ b/custom_components/hacs/manifest.json @@ -17,11 +17,11 @@ "name": "HACS", "requirements": [ "aiofiles>=0.6.0", - "aiogithubapi>=21.4.0", + "aiogithubapi>=21.8.1", "awesomeversion>=21.2.2", "backoff>=1.10.0", - "hacs_frontend==20210429001005", + "hacs_frontend==20210822172723", "queueman==0.5" ], - "version": "1.12.3" + "version": "1.15.2" } \ No newline at end of file diff --git a/custom_components/hacs/mixin.py b/custom_components/hacs/mixin.py new file mode 100644 index 00000000..3283e8c3 --- /dev/null +++ b/custom_components/hacs/mixin.py @@ -0,0 +1,24 @@ +"""Mixin classes.""" +# pylint: disable=too-few-public-methods +from __future__ import annotations + +from logging import Logger +from typing import TYPE_CHECKING + +from .share import get_hacs +from .utils.logger import getLogger + +if TYPE_CHECKING: + from .hacsbase.hacs import Hacs + + +class HacsMixin: + """Mixin to provide 'self.hacs' to classes.""" + + hacs: Hacs = get_hacs() + + +class LogMixin: + """Mixin to provide 'self.log' to classes.""" + + log: Logger = getLogger() diff --git a/custom_components/hacs/models/__init__.py b/custom_components/hacs/models/__init__.py deleted file mode 100644 index 3fb384a2..00000000 --- a/custom_components/hacs/models/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Hacs models.""" diff --git a/custom_components/hacs/models/core.py b/custom_components/hacs/models/core.py deleted file mode 100644 index 1cffaa5a..00000000 --- a/custom_components/hacs/models/core.py +++ /dev/null @@ -1,15 +0,0 @@ -"""HACS Core info.""" -from pathlib import Path - -import attr - -from ..enums import LovelaceMode - - -@attr.s -class HacsCore: - """HACS Core info.""" - - config_path = attr.ib(Path) - ha_version = attr.ib(str) - lovelace_mode = LovelaceMode("storage") diff --git a/custom_components/hacs/models/frontend.py b/custom_components/hacs/models/frontend.py deleted file mode 100644 index 1b9d875d..00000000 --- a/custom_components/hacs/models/frontend.py +++ /dev/null @@ -1,10 +0,0 @@ -"""HacsFrontend.""" - - -class HacsFrontend: - """HacsFrontend.""" - - version_running: bool = None - version_available: bool = None - version_expected: bool = None - update_pending: bool = False diff --git a/custom_components/hacs/models/system.py b/custom_components/hacs/models/system.py deleted file mode 100644 index 4923c624..00000000 --- a/custom_components/hacs/models/system.py +++ /dev/null @@ -1,18 +0,0 @@ -"""HACS System info.""" -from typing import Optional -import attr - -from ..const import INTEGRATION_VERSION -from ..enums import HacsStage - - -@attr.s -class HacsSystem: - """HACS System info.""" - - disabled: bool = False - disabled_reason: Optional[str] = None - running: bool = False - version: str = INTEGRATION_VERSION - stage: HacsStage = attr.ib(HacsStage) - action: bool = False diff --git a/custom_components/hacs/operational/backup.py b/custom_components/hacs/operational/backup.py index 4edf8eb3..b1770209 100644 --- a/custom_components/hacs/operational/backup.py +++ b/custom_components/hacs/operational/backup.py @@ -5,7 +5,7 @@ from time import sleep from custom_components.hacs.helpers.functions.is_safe_to_remove import is_safe_to_remove -from custom_components.hacs.helpers.functions.logger import getLogger +from custom_components.hacs.utils.logger import getLogger BACKUP_PATH = tempfile.gettempdir() + "/hacs_backup/" @@ -65,9 +65,7 @@ def restore(self): while os.path.exists(self.local_path): sleep(0.1) shutil.copytree(self.backup_path_full, self.local_path) - _LOGGER.debug( - "Restored %s, from backup %s", self.local_path, self.backup_path_full - ) + _LOGGER.debug("Restored %s, from backup %s", self.local_path, self.backup_path_full) def cleanup(self): """Cleanup backup files.""" @@ -110,9 +108,7 @@ def restore(self): for filename in os.listdir(self.backup_path): if filename.endswith(".yaml"): source_file_name = f"{self.backup_path}/{filename}" - target_file_name = ( - f"{self.repository.content.path.local}/{filename}" - ) + target_file_name = f"{self.repository.content.path.local}/{filename}" shutil.copyfile(source_file_name, target_file_name) def cleanup(self): diff --git a/custom_components/hacs/operational/factory.py b/custom_components/hacs/operational/factory.py index f40de663..5e045c52 100644 --- a/custom_components/hacs/operational/factory.py +++ b/custom_components/hacs/operational/factory.py @@ -3,15 +3,15 @@ from aiogithubapi import AIOGitHubAPIException -from custom_components.hacs.helpers.classes.exceptions import ( +from custom_components.hacs.exceptions import ( HacsException, HacsNotModifiedException, HacsRepositoryArchivedException, ) -from custom_components.hacs.helpers.functions.logger import getLogger from custom_components.hacs.helpers.functions.register_repository import ( register_repository, ) +from custom_components.hacs.utils.logger import getLogger max_concurrent_tasks = asyncio.Semaphore(15) sleeper = 5 diff --git a/custom_components/hacs/operational/reload.py b/custom_components/hacs/operational/reload.py deleted file mode 100644 index 037887e6..00000000 --- a/custom_components/hacs/operational/reload.py +++ /dev/null @@ -1,10 +0,0 @@ -"""Reload HACS""" - - -async def async_reload_entry(hass, config_entry): - """Reload HACS.""" - from custom_components.hacs.operational.remove import async_remove_entry - from custom_components.hacs.operational.setup import async_setup_entry - - await async_remove_entry(hass, config_entry) - await async_setup_entry(hass, config_entry) diff --git a/custom_components/hacs/operational/remove.py b/custom_components/hacs/operational/remove.py deleted file mode 100644 index b09c5477..00000000 --- a/custom_components/hacs/operational/remove.py +++ /dev/null @@ -1,28 +0,0 @@ -"""Remove HACS.""" -from ..const import DOMAIN -from ..enums import HacsDisabledReason -from ..share import get_hacs - - -async def async_remove_entry(hass, config_entry): - """Handle removal of an entry.""" - hacs = get_hacs() - hacs.log.info("Disabling HACS") - hacs.log.info("Removing recurring tasks") - for task in hacs.recuring_tasks: - task() - if config_entry.state == "loaded": - hacs.log.info("Removing sensor") - try: - await hass.config_entries.async_forward_entry_unload(config_entry, "sensor") - except ValueError: - pass - try: - if "hacs" in hass.data.get("frontend_panels", {}): - hacs.log.info("Removing sidepanel") - hass.components.frontend.async_remove_panel("hacs") - except AttributeError: - pass - if DOMAIN in hass.data: - del hass.data[DOMAIN] - hacs.disable(HacsDisabledReason.REMOVED) diff --git a/custom_components/hacs/operational/runtime.py b/custom_components/hacs/operational/runtime.py deleted file mode 100644 index 95f5985b..00000000 --- a/custom_components/hacs/operational/runtime.py +++ /dev/null @@ -1 +0,0 @@ -"""Runtime...""" diff --git a/custom_components/hacs/operational/setup.py b/custom_components/hacs/operational/setup.py index c355e081..14a46baa 100644 --- a/custom_components/hacs/operational/setup.py +++ b/custom_components/hacs/operational/setup.py @@ -1,42 +1,19 @@ """Setup HACS.""" -from datetime import datetime -from aiogithubapi import AIOGitHubAPIException, GitHub -from homeassistant.const import EVENT_HOMEASSISTANT_STARTED -from homeassistant.const import __version__ as HAVERSION -from homeassistant.core import CoreState +from aiogithubapi import AIOGitHubAPIException, GitHub, GitHubAPI +from aiogithubapi.const import ACCEPT_HEADERS +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry +from homeassistant.const import EVENT_HOMEASSISTANT_STARTED, __version__ as HAVERSION +from homeassistant.core import CoreState, HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError from homeassistant.helpers.aiohttp_client import async_create_clientsession from homeassistant.helpers.event import async_call_later +from homeassistant.loader import async_get_integration -from custom_components.hacs.const import ( - DOMAIN, - HACS_GITHUB_API_HEADERS, - INTEGRATION_VERSION, - STARTUP, -) -from custom_components.hacs.enums import HacsDisabledReason, HacsStage -from custom_components.hacs.hacsbase.configuration import Configuration +from custom_components.hacs.const import DOMAIN, STARTUP +from custom_components.hacs.enums import ConfigurationType, HacsStage, LovelaceMode from custom_components.hacs.hacsbase.data import HacsData -from custom_components.hacs.helpers.functions.constrains import check_constrains -from custom_components.hacs.helpers.functions.remaining_github_calls import ( - get_fetch_updates_for, -) -from custom_components.hacs.operational.reload import async_reload_entry -from custom_components.hacs.operational.remove import async_remove_entry -from custom_components.hacs.operational.setup_actions.clear_storage import ( - async_clear_storage, -) -from custom_components.hacs.operational.setup_actions.frontend import ( - async_setup_frontend, -) -from custom_components.hacs.operational.setup_actions.load_hacs_repository import ( - async_load_hacs_repository, -) -from custom_components.hacs.operational.setup_actions.sensor import async_add_sensor -from custom_components.hacs.operational.setup_actions.websocket_api import ( - async_setup_hacs_websockt_api, -) from custom_components.hacs.share import get_hacs +from custom_components.hacs.tasks.manager import HacsTaskManager try: from homeassistant.components.lovelace import system_health_info @@ -44,33 +21,82 @@ from homeassistant.components.lovelace.system_health import system_health_info -async def _async_common_setup(hass): +async def _async_common_setup(hass: HomeAssistant): """Common setup stages.""" + integration = await async_get_integration(hass, DOMAIN) + hacs = get_hacs() + + hacs.enable_hacs() + await hacs.async_set_stage(None) + + hacs.log.info(STARTUP.format(version=integration.version)) + + hacs.integration = integration + hacs.version = integration.version hacs.hass = hass + hacs.data = HacsData() hacs.system.running = True hacs.session = async_create_clientsession(hass) + hacs.tasks = HacsTaskManager(hacs=hacs, hass=hass) + + try: + lovelace_info = await system_health_info(hacs.hass) + except (TypeError, KeyError, HomeAssistantError): + # If this happens, the users YAML is not valid, we assume YAML mode + lovelace_info = {"mode": "yaml"} + hacs.log.debug(f"Configuration type: {hacs.configuration.config_type}") + hacs.core.config_path = hacs.hass.config.path() + hacs.core.ha_version = HAVERSION + hacs.core.lovelace_mode = lovelace_info.get("mode", "yaml") + hacs.core.lovelace_mode = LovelaceMode(lovelace_info.get("mode", "yaml")) -async def async_setup_entry(hass, config_entry): - """Set up this integration using UI.""" - from homeassistant import config_entries + await hacs.tasks.async_load() + + # Setup session for API clients + session = async_create_clientsession(hacs.hass) + ## Legacy GitHub client + hacs.github = GitHub( + hacs.configuration.token, + session, + headers={ + "User-Agent": f"HACS/{hacs.version}", + "Accept": ACCEPT_HEADERS["preview"], + }, + ) + + ## New GitHub client + hacs.githubapi = GitHubAPI( + token=hacs.configuration.token, + session=session, + **{"client_name": f"HACS/{hacs.version}"}, + ) + + hass.data[DOMAIN] = hacs + + +async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: + """Set up this integration using UI.""" hacs = get_hacs() - if hass.data.get(DOMAIN) is not None: - return False - if config_entry.source == config_entries.SOURCE_IMPORT: + + if config_entry.source == SOURCE_IMPORT: hass.async_create_task(hass.config_entries.async_remove(config_entry.entry_id)) return False + if hass.data.get(DOMAIN) is not None: + return False - await _async_common_setup(hass) - - hacs.configuration = Configuration.from_dict( - config_entry.data, config_entry.options + hacs.configuration.update_from_dict( + { + "config_entry": config_entry, + "config_type": ConfigurationType.CONFIG_ENTRY, + **config_entry.data, + **config_entry.options, + } ) - hacs.configuration.config_type = "flow" - hacs.configuration.config_entry = config_entry + await _async_common_setup(hass) return await async_startup_wrapper_for_config_entry() @@ -79,13 +105,18 @@ async def async_setup(hass, config): hacs = get_hacs() if DOMAIN not in config: return True - if hacs.configuration and hacs.configuration.config_type == "flow": + if hacs.configuration.config_type == ConfigurationType.CONFIG_ENTRY: return True - await _async_common_setup(hass) + hacs.configuration.update_from_dict( + { + "config_type": ConfigurationType.YAML, + **config[DOMAIN], + "config": config[DOMAIN], + } + ) - hacs.configuration = Configuration.from_dict(config[DOMAIN]) - hacs.configuration.config_type = "yaml" + await _async_common_setup(hass) await async_startup_wrapper_for_yaml() return True @@ -93,7 +124,7 @@ async def async_setup(hass, config): async def async_startup_wrapper_for_config_entry(): """Startup wrapper for ui config.""" hacs = get_hacs() - hacs.configuration.config_entry.add_update_listener(async_reload_entry) + try: startup_result = await async_hacs_startup() except AIOGitHubAPIException: @@ -101,7 +132,7 @@ async def async_startup_wrapper_for_config_entry(): if not startup_result: hacs.system.disabled = True raise ConfigEntryNotReady - hacs.enable() + hacs.enable_hacs() return startup_result @@ -117,86 +148,19 @@ async def async_startup_wrapper_for_yaml(_=None): hacs.log.info("Could not setup HACS, trying again in 15 min") async_call_later(hacs.hass, 900, async_startup_wrapper_for_yaml) return - hacs.enable() + hacs.enable_hacs() async def async_hacs_startup(): """HACS startup tasks.""" hacs = get_hacs() - hacs.hass.data[DOMAIN] = hacs - - try: - lovelace_info = await system_health_info(hacs.hass) - except (TypeError, HomeAssistantError): - # If this happens, the users YAML is not valid, we assume YAML mode - lovelace_info = {"mode": "yaml"} - hacs.log.debug(f"Configuration type: {hacs.configuration.config_type}") - hacs.version = INTEGRATION_VERSION - hacs.log.info(STARTUP) - hacs.core.config_path = hacs.hass.config.path() - hacs.system.ha_version = HAVERSION - - # Setup websocket API - await async_setup_hacs_websockt_api() - - # Set up frontend - await async_setup_frontend() - - # Clear old storage files - await async_clear_storage() - - hacs.system.lovelace_mode = lovelace_info.get("mode", "yaml") - hacs.enable() - hacs.github = GitHub( - hacs.configuration.token, - async_create_clientsession(hacs.hass), - headers=HACS_GITHUB_API_HEADERS, - ) - hacs.data = HacsData() - can_update = await get_fetch_updates_for(hacs.github) - if can_update is None: - hacs.log.critical("Your GitHub token is not valid") - hacs.disable(HacsDisabledReason.INVALID_TOKEN) + await hacs.async_set_stage(HacsStage.SETUP) + if hacs.system.disabled: return False - if can_update != 0: - hacs.log.debug(f"Can update {can_update} repositories") - else: - reset = datetime.fromtimestamp(int(hacs.github.client.ratelimits.reset)) - hacs.log.error( - "HACS is ratelimited, HACS will resume setup when the limit is cleared (%02d:%02d:%02d)", - reset.hour, - reset.minute, - reset.second, - ) - hacs.disable(HacsDisabledReason.RATE_LIMIT) - return False - - # Check HACS Constrains - if not await hacs.hass.async_add_executor_job(check_constrains): - if hacs.configuration.config_type == "flow": - if hacs.configuration.config_entry is not None: - await async_remove_entry(hacs.hass, hacs.configuration.config_entry) - hacs.disable(HacsDisabledReason.CONSTRAINS) - return False - - # Load HACS - if not await async_load_hacs_repository(): - if hacs.configuration.config_type == "flow": - if hacs.configuration.config_entry is not None: - await async_remove_entry(hacs.hass, hacs.configuration.config_entry) - hacs.disable(HacsDisabledReason.LOAD_HACS) - return False - - # Restore from storefiles - if not await hacs.data.restore(): - hacs_repo = hacs.get_by_name("hacs/integration") - hacs_repo.pending_restart = True - if hacs.configuration.config_type == "flow": - if hacs.configuration.config_entry is not None: - await async_remove_entry(hacs.hass, hacs.configuration.config_entry) - hacs.disable(HacsDisabledReason.RESTORE) + await hacs.async_set_stage(HacsStage.STARTUP) + if hacs.system.disabled: return False # Setup startup tasks @@ -205,12 +169,8 @@ async def async_hacs_startup(): else: hacs.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STARTED, hacs.startup_tasks) - # Set up sensor - await async_add_sensor() - # Mischief managed! await hacs.async_set_stage(HacsStage.WAITING) - hacs.log.info( - "Setup complete, waiting for Home Assistant before startup tasks starts" - ) - return True + hacs.log.info("Setup complete, waiting for Home Assistant before startup tasks starts") + + return not hacs.system.disabled diff --git a/custom_components/hacs/operational/setup_actions/__init__.py b/custom_components/hacs/operational/setup_actions/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/custom_components/hacs/operational/setup_actions/categories.py b/custom_components/hacs/operational/setup_actions/categories.py deleted file mode 100644 index 5092f66a..00000000 --- a/custom_components/hacs/operational/setup_actions/categories.py +++ /dev/null @@ -1,42 +0,0 @@ -"""Starting setup task: extra stores.""" -from custom_components.hacs.const import ELEMENT_TYPES - -from ...enums import HacsCategory, HacsSetupTask -from ...share import get_hacs - - -def _setup_extra_stores(): - """Set up extra stores in HACS if enabled in Home Assistant.""" - hacs = get_hacs() - hacs.log.debug("Starting setup task: Extra stores") - hacs.common.categories = set() - for category in ELEMENT_TYPES: - enable_category(hacs, HacsCategory(category)) - - if HacsCategory.PYTHON_SCRIPT in hacs.hass.config.components: - enable_category(hacs, HacsCategory.PYTHON_SCRIPT) - - if ( - hacs.hass.services._services.get("frontend", {}).get("reload_themes") - is not None - ): - enable_category(hacs, HacsCategory.THEME) - - if hacs.configuration.appdaemon: - enable_category(hacs, HacsCategory.APPDAEMON) - if hacs.configuration.netdaemon: - enable_category(hacs, HacsCategory.NETDAEMON) - - -async def async_setup_extra_stores(): - """Async wrapper for setup_extra_stores""" - hacs = get_hacs() - hacs.log.info("setup task %s", HacsSetupTask.CATEGORIES) - await hacs.hass.async_add_executor_job(_setup_extra_stores) - - -def enable_category(hacs, category: HacsCategory): - """Add category.""" - if category not in hacs.common.categories: - hacs.log.info("Enable category: %s", category) - hacs.common.categories.add(category) diff --git a/custom_components/hacs/operational/setup_actions/clear_storage.py b/custom_components/hacs/operational/setup_actions/clear_storage.py deleted file mode 100644 index c67aaafa..00000000 --- a/custom_components/hacs/operational/setup_actions/clear_storage.py +++ /dev/null @@ -1,24 +0,0 @@ -"""Starting setup task: clear storage.""" -import os - -from custom_components.hacs.share import get_hacs - -from ...enums import HacsSetupTask - - -async def async_clear_storage(): - """Async wrapper for clear_storage""" - hacs = get_hacs() - hacs.log.info("Setup task %s", HacsSetupTask.CATEGORIES) - await hacs.hass.async_add_executor_job(_clear_storage) - - -def _clear_storage(): - """Clear old files from storage.""" - hacs = get_hacs() - storagefiles = ["hacs"] - for s_f in storagefiles: - path = f"{hacs.core.config_path}/.storage/{s_f}" - if os.path.isfile(path): - hacs.log.info(f"Cleaning up old storage file {path}") - os.remove(path) diff --git a/custom_components/hacs/operational/setup_actions/frontend.py b/custom_components/hacs/operational/setup_actions/frontend.py deleted file mode 100644 index 1ad86936..00000000 --- a/custom_components/hacs/operational/setup_actions/frontend.py +++ /dev/null @@ -1,70 +0,0 @@ -from hacs_frontend.version import VERSION as FE_VERSION -from hacs_frontend import locate_dir - -from custom_components.hacs.helpers.functions.logger import getLogger -from custom_components.hacs.webresponses.frontend import HacsFrontendDev -from custom_components.hacs.helpers.functions.information import get_frontend_version -from custom_components.hacs.share import get_hacs - -from ...enums import HacsSetupTask - - -URL_BASE = "/hacsfiles" - - -async def async_setup_frontend(): - """Configure the HACS frontend elements.""" - hacs = get_hacs() - hacs.log.info("Setup task %s", HacsSetupTask.FRONTEND) - hass = hacs.hass - - # Register themes - hass.http.register_static_path(f"{URL_BASE}/themes", hass.config.path("themes")) - - # Register frontend - if hacs.configuration.frontend_repo_url: - getLogger().warning( - "Frontend development mode enabled. Do not run in production." - ) - hass.http.register_view(HacsFrontendDev()) - else: - # - hass.http.register_static_path( - f"{URL_BASE}/frontend", locate_dir(), cache_headers=False - ) - - # Custom iconset - hass.http.register_static_path( - f"{URL_BASE}/iconset.js", str(hacs.integration_dir / "iconset.js") - ) - if "frontend_extra_module_url" not in hass.data: - hass.data["frontend_extra_module_url"] = set() - hass.data["frontend_extra_module_url"].add("/hacsfiles/iconset.js") - - # Register www/community for all other files - hass.http.register_static_path( - URL_BASE, hass.config.path("www/community"), cache_headers=False - ) - - hacs.frontend.version_running = FE_VERSION - hacs.frontend.version_expected = await hass.async_add_executor_job( - get_frontend_version - ) - - # Add to sidepanel - if "hacs" not in hass.data.get("frontend_panels", {}): - hass.components.frontend.async_register_built_in_panel( - component_name="custom", - sidebar_title=hacs.configuration.sidepanel_title, - sidebar_icon=hacs.configuration.sidepanel_icon, - frontend_url_path="hacs", - config={ - "_panel_custom": { - "name": "hacs-frontend", - "embed_iframe": True, - "trust_external": False, - "js_url": "/hacsfiles/frontend/entrypoint.js", - } - }, - require_admin=True, - ) diff --git a/custom_components/hacs/operational/setup_actions/load_hacs_repository.py b/custom_components/hacs/operational/setup_actions/load_hacs_repository.py deleted file mode 100644 index 0cce8e9a..00000000 --- a/custom_components/hacs/operational/setup_actions/load_hacs_repository.py +++ /dev/null @@ -1,38 +0,0 @@ -"""Starting setup task: load HACS repository.""" -from custom_components.hacs.const import INTEGRATION_VERSION -from custom_components.hacs.helpers.classes.exceptions import HacsException -from custom_components.hacs.helpers.functions.information import get_repository -from custom_components.hacs.helpers.functions.register_repository import ( - register_repository, -) -from custom_components.hacs.share import get_hacs - -from ...enums import HacsSetupTask - - -async def async_load_hacs_repository(): - """Load HACS repositroy.""" - hacs = get_hacs() - hacs.log.info("Setup task %s", HacsSetupTask.HACS_REPO) - - try: - repository = hacs.get_by_name("hacs/integration") - if repository is None: - await register_repository("hacs/integration", "integration") - repository = hacs.get_by_name("hacs/integration") - if repository is None: - raise HacsException("Unknown error") - repository.data.installed = True - repository.data.installed_version = INTEGRATION_VERSION - repository.data.new = False - hacs.repo = repository.repository_object - hacs.data_repo, _ = await get_repository( - hacs.session, hacs.configuration.token, "hacs/default", None - ) - except HacsException as exception: - if "403" in f"{exception}": - hacs.log.critical("GitHub API is ratelimited, or the token is wrong.") - else: - hacs.log.critical(f"[{exception}] - Could not load HACS!") - return False - return True diff --git a/custom_components/hacs/operational/setup_actions/sensor.py b/custom_components/hacs/operational/setup_actions/sensor.py deleted file mode 100644 index 13be265c..00000000 --- a/custom_components/hacs/operational/setup_actions/sensor.py +++ /dev/null @@ -1,25 +0,0 @@ -""""Starting setup task: Sensor".""" -from homeassistant.helpers import discovery - -from custom_components.hacs.const import DOMAIN -from custom_components.hacs.share import get_hacs - -from ...enums import HacsSetupTask - - -async def async_add_sensor(): - """Async wrapper for add sensor""" - hacs = get_hacs() - hacs.log.info("Setup task %s", HacsSetupTask.SENSOR) - if hacs.configuration.config_type == "yaml": - hacs.hass.async_create_task( - discovery.async_load_platform( - hacs.hass, "sensor", DOMAIN, {}, hacs.configuration.config - ) - ) - else: - hacs.hass.async_add_job( - hacs.hass.config_entries.async_forward_entry_setup( - hacs.configuration.config_entry, "sensor" - ) - ) diff --git a/custom_components/hacs/operational/setup_actions/websocket_api.py b/custom_components/hacs/operational/setup_actions/websocket_api.py deleted file mode 100644 index cf3e5b3a..00000000 --- a/custom_components/hacs/operational/setup_actions/websocket_api.py +++ /dev/null @@ -1,36 +0,0 @@ -"""Register WS API endpoints for HACS.""" -from homeassistant.components import websocket_api - -from custom_components.hacs.api.acknowledge_critical_repository import ( - acknowledge_critical_repository, -) -from custom_components.hacs.api.check_local_path import check_local_path -from custom_components.hacs.api.get_critical_repositories import ( - get_critical_repositories, -) -from custom_components.hacs.api.hacs_config import hacs_config -from custom_components.hacs.api.hacs_removed import hacs_removed -from custom_components.hacs.api.hacs_repositories import hacs_repositories -from custom_components.hacs.api.hacs_repository import hacs_repository -from custom_components.hacs.api.hacs_repository_data import hacs_repository_data -from custom_components.hacs.api.hacs_settings import hacs_settings -from custom_components.hacs.api.hacs_status import hacs_status -from custom_components.hacs.share import get_hacs - -from ...enums import HacsSetupTask - - -async def async_setup_hacs_websockt_api(): - """Set up WS API handlers.""" - hacs = get_hacs() - hacs.log.info("Setup task %s", HacsSetupTask.WEBSOCKET) - websocket_api.async_register_command(hacs.hass, hacs_settings) - websocket_api.async_register_command(hacs.hass, hacs_config) - websocket_api.async_register_command(hacs.hass, hacs_repositories) - websocket_api.async_register_command(hacs.hass, hacs_repository) - websocket_api.async_register_command(hacs.hass, hacs_repository_data) - websocket_api.async_register_command(hacs.hass, check_local_path) - websocket_api.async_register_command(hacs.hass, hacs_status) - websocket_api.async_register_command(hacs.hass, hacs_removed) - websocket_api.async_register_command(hacs.hass, acknowledge_critical_repository) - websocket_api.async_register_command(hacs.hass, get_critical_repositories) diff --git a/custom_components/hacs/repositories/__init__.py b/custom_components/hacs/repositories/__init__.py index 91d89c42..d78def45 100644 --- a/custom_components/hacs/repositories/__init__.py +++ b/custom_components/hacs/repositories/__init__.py @@ -1,16 +1,16 @@ """Initialize repositories.""" -from custom_components.hacs.repositories.appdaemon import HacsAppdaemon -from custom_components.hacs.repositories.integration import HacsIntegration -from custom_components.hacs.repositories.netdaemon import HacsNetdaemon -from custom_components.hacs.repositories.plugin import HacsPlugin -from custom_components.hacs.repositories.python_script import HacsPythonScript -from custom_components.hacs.repositories.theme import HacsTheme +from custom_components.hacs.repositories.appdaemon import HacsAppdaemonRepository +from custom_components.hacs.repositories.integration import HacsIntegrationRepository +from custom_components.hacs.repositories.netdaemon import HacsNetdaemonRepository +from custom_components.hacs.repositories.plugin import HacsPluginRepository +from custom_components.hacs.repositories.python_script import HacsPythonScriptRepository +from custom_components.hacs.repositories.theme import HacsThemeRepository RERPOSITORY_CLASSES = { - "theme": HacsTheme, - "integration": HacsIntegration, - "python_script": HacsPythonScript, - "appdaemon": HacsAppdaemon, - "netdaemon": HacsNetdaemon, - "plugin": HacsPlugin, + "theme": HacsThemeRepository, + "integration": HacsIntegrationRepository, + "python_script": HacsPythonScriptRepository, + "appdaemon": HacsAppdaemonRepository, + "netdaemon": HacsNetdaemonRepository, + "plugin": HacsPluginRepository, } diff --git a/custom_components/hacs/repositories/appdaemon.py b/custom_components/hacs/repositories/appdaemon.py index c580b57c..a74186b0 100644 --- a/custom_components/hacs/repositories/appdaemon.py +++ b/custom_components/hacs/repositories/appdaemon.py @@ -2,11 +2,11 @@ from aiogithubapi import AIOGitHubAPIException from custom_components.hacs.enums import HacsCategory -from custom_components.hacs.helpers.classes.exceptions import HacsException +from custom_components.hacs.exceptions import HacsException from custom_components.hacs.helpers.classes.repository import HacsRepository -class HacsAppdaemon(HacsRepository): +class HacsAppdaemonRepository(HacsRepository): """Appdaemon apps in HACS.""" def __init__(self, full_name): @@ -61,9 +61,7 @@ async def update_repository(self, ignore_issues=False, force=False): self.content.path.remote = "" if self.content.path.remote == "apps": - addir = await self.repository_object.get_contents( - self.content.path.remote, self.ref - ) + addir = await self.repository_object.get_contents(self.content.path.remote, self.ref) self.content.path.remote = addir[0].path self.content.objects = await self.repository_object.get_contents( self.content.path.remote, self.ref diff --git a/custom_components/hacs/repositories/integration.py b/custom_components/hacs/repositories/integration.py index c73fe646..80758210 100644 --- a/custom_components/hacs/repositories/integration.py +++ b/custom_components/hacs/repositories/integration.py @@ -2,7 +2,7 @@ from homeassistant.loader import async_get_custom_components from custom_components.hacs.enums import HacsCategory -from custom_components.hacs.helpers.classes.exceptions import HacsException +from custom_components.hacs.exceptions import HacsException from custom_components.hacs.helpers.classes.repository import HacsRepository from custom_components.hacs.helpers.functions.filters import ( get_first_directory_in_directory, @@ -10,10 +10,9 @@ from custom_components.hacs.helpers.functions.information import ( get_integration_manifest, ) -from custom_components.hacs.helpers.functions.logger import getLogger -class HacsIntegration(HacsRepository): +class HacsIntegrationRepository(HacsRepository): """Integrations in HACS.""" def __init__(self, full_name): diff --git a/custom_components/hacs/repositories/netdaemon.py b/custom_components/hacs/repositories/netdaemon.py index ba4127f9..6796e71f 100644 --- a/custom_components/hacs/repositories/netdaemon.py +++ b/custom_components/hacs/repositories/netdaemon.py @@ -1,14 +1,13 @@ """Class for netdaemon apps in HACS.""" from custom_components.hacs.enums import HacsCategory -from custom_components.hacs.helpers.classes.exceptions import HacsException +from custom_components.hacs.exceptions import HacsException from custom_components.hacs.helpers.classes.repository import HacsRepository from custom_components.hacs.helpers.functions.filters import ( get_first_directory_in_directory, ) -from custom_components.hacs.helpers.functions.logger import getLogger -class HacsNetdaemon(HacsRepository): +class HacsNetdaemonRepository(HacsRepository): """Netdaemon apps in HACS.""" def __init__(self, full_name): @@ -35,16 +34,12 @@ async def validate_repository(self): self.content.path.remote = "" if self.content.path.remote == "apps": - self.data.domain = get_first_directory_in_directory( - self.tree, self.content.path.remote - ) + self.data.domain = 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" - ): + if treefile.startswith(f"{self.content.path.remote}") and treefile.endswith(".cs"): compliant = True break if not compliant: @@ -70,9 +65,7 @@ async def update_repository(self, ignore_issues=False, force=False): self.content.path.remote = "" if self.content.path.remote == "apps": - self.data.domain = get_first_directory_in_directory( - self.tree, self.content.path.remote - ) + self.data.domain = get_first_directory_in_directory(self.tree, self.content.path.remote) self.content.path.remote = f"apps/{self.data.name}" # Set local path diff --git a/custom_components/hacs/repositories/plugin.py b/custom_components/hacs/repositories/plugin.py index 8814eaac..1c0e026f 100644 --- a/custom_components/hacs/repositories/plugin.py +++ b/custom_components/hacs/repositories/plugin.py @@ -1,13 +1,12 @@ """Class for plugins in HACS.""" import json -from custom_components.hacs.helpers.classes.exceptions import HacsException +from custom_components.hacs.exceptions import HacsException from custom_components.hacs.helpers.classes.repository import HacsRepository from custom_components.hacs.helpers.functions.information import find_file_name -from custom_components.hacs.helpers.functions.logger import getLogger -class HacsPlugin(HacsRepository): +class HacsPluginRepository(HacsRepository): """Plugins in HACS.""" def __init__(self, full_name): @@ -67,9 +66,7 @@ async def update_repository(self, ignore_issues=False, force=False): async def get_package_content(self): """Get package content.""" try: - package = await self.repository_object.get_contents( - "package.json", self.ref - ) + package = await self.repository_object.get_contents("package.json", self.ref) package = json.loads(package.content) if package: diff --git a/custom_components/hacs/repositories/python_script.py b/custom_components/hacs/repositories/python_script.py index 00860b6e..50c9cfab 100644 --- a/custom_components/hacs/repositories/python_script.py +++ b/custom_components/hacs/repositories/python_script.py @@ -1,12 +1,11 @@ """Class for python_scripts in HACS.""" from custom_components.hacs.enums import HacsCategory -from custom_components.hacs.helpers.classes.exceptions import HacsException +from custom_components.hacs.exceptions import HacsException from custom_components.hacs.helpers.classes.repository import HacsRepository from custom_components.hacs.helpers.functions.information import find_file_name -from custom_components.hacs.helpers.functions.logger import getLogger -class HacsPythonScript(HacsRepository): +class HacsPythonScriptRepository(HacsRepository): """python_scripts in HACS.""" category = "python_script" @@ -37,9 +36,7 @@ async def validate_repository(self): compliant = False for treefile in self.treefiles: - if treefile.startswith(f"{self.content.path.remote}") and treefile.endswith( - ".py" - ): + if treefile.startswith(f"{self.content.path.remote}") and treefile.endswith(".py"): compliant = True break if not compliant: @@ -70,9 +67,7 @@ async def update_repository(self, ignore_issues=False, force=False): compliant = False for treefile in self.treefiles: - if treefile.startswith(f"{self.content.path.remote}") and treefile.endswith( - ".py" - ): + if treefile.startswith(f"{self.content.path.remote}") and treefile.endswith(".py"): compliant = True break if not compliant: diff --git a/custom_components/hacs/repositories/theme.py b/custom_components/hacs/repositories/theme.py index 4ce938ca..fcc61dbb 100644 --- a/custom_components/hacs/repositories/theme.py +++ b/custom_components/hacs/repositories/theme.py @@ -1,12 +1,11 @@ """Class for themes in HACS.""" from custom_components.hacs.enums import HacsCategory -from custom_components.hacs.helpers.classes.exceptions import HacsException +from custom_components.hacs.exceptions import HacsException from custom_components.hacs.helpers.classes.repository import HacsRepository from custom_components.hacs.helpers.functions.information import find_file_name -from custom_components.hacs.helpers.functions.logger import getLogger -class HacsTheme(HacsRepository): +class HacsThemeRepository(HacsRepository): """Themes in HACS.""" def __init__(self, full_name): diff --git a/custom_components/hacs/sensor.py b/custom_components/hacs/sensor.py index d5f6ceab..c7accc9f 100644 --- a/custom_components/hacs/sensor.py +++ b/custom_components/hacs/sensor.py @@ -2,13 +2,11 @@ from homeassistant.core import callback from homeassistant.helpers.entity import Entity -from custom_components.hacs.const import DOMAIN, INTEGRATION_VERSION, NAME_SHORT -from custom_components.hacs.share import get_hacs +from custom_components.hacs.const import DOMAIN, NAME_SHORT +from custom_components.hacs.mixin import HacsMixin -async def async_setup_platform( - _hass, _config, async_add_entities, _discovery_info=None -): +async def async_setup_platform(_hass, _config, async_add_entities, _discovery_info=None): """Setup sensor platform.""" async_add_entities([HACSSensor()]) @@ -18,7 +16,7 @@ async def async_setup_entry(_hass, _config_entry, async_add_devices): async_add_devices([HACSSensor()]) -class HACSDevice(Entity): +class HACSDevice(HacsMixin, Entity): """HACS Device class.""" @property @@ -29,7 +27,7 @@ def device_info(self): "name": NAME_SHORT, "manufacturer": "hacs.xyz", "model": "", - "sw_version": INTEGRATION_VERSION, + "sw_version": str(self.hacs.version), "entry_type": "service", } @@ -60,16 +58,15 @@ def _update_and_write_state(self, *_): @callback def _update(self): """Update the sensor.""" - hacs = get_hacs() - if hacs.status.background_task: + if self.hacs.status.background_task: return self.repositories = [] - for repository in hacs.repositories: + for repository in self.hacs.repositories: if ( repository.pending_upgrade - and repository.data.category in hacs.common.categories + and repository.data.category in self.hacs.common.categories ): self.repositories.append(repository) self._state = len(self.repositories) @@ -77,9 +74,7 @@ def _update(self): @property def unique_id(self): """Return a unique ID to use for this sensor.""" - return ( - "0717a0cd-745c-48fd-9b16-c8534c9704f9-bc944b0f-fd42-4a58-a072-ade38d1444cd" - ) + return "0717a0cd-745c-48fd-9b16-c8534c9704f9-bc944b0f-fd42-4a58-a072-ade38d1444cd" @property def name(self): diff --git a/custom_components/hacs/share.py b/custom_components/hacs/share.py index a3665c2b..29c19be8 100644 --- a/custom_components/hacs/share.py +++ b/custom_components/hacs/share.py @@ -56,9 +56,7 @@ def get_removed(repository): removed_repo.repository = repository SHARE["removed_repositories"].append(removed_repo) filter_repos = [ - x - for x in SHARE["removed_repositories"] - if x.repository.lower() == repository.lower() + x for x in SHARE["removed_repositories"] if x.repository.lower() == repository.lower() ] return filter_repos.pop() or None diff --git a/custom_components/hacs/system_health.py b/custom_components/hacs/system_health.py index 37c03d1d..1a7860a2 100644 --- a/custom_components/hacs/system_health.py +++ b/custom_components/hacs/system_health.py @@ -10,9 +10,7 @@ @callback -def async_register( - hass: HomeAssistant, register: system_health.SystemHealthRegistration -) -> None: +def async_register(hass: HomeAssistant, register: system_health.SystemHealthRegistration) -> None: """Register system health callbacks.""" register.domain = "Home Assistant Community Store" register.async_register_info(system_health_info, "/hacs") @@ -20,18 +18,19 @@ def async_register( async def system_health_info(hass): """Get info for the info page.""" - client: HacsBase = hass.data[DOMAIN] - rate_limit = await client.github.get_rate_limit() - - return { - "GitHub API": system_health.async_check_can_reach_url( - hass, BASE_API_URL, GITHUB_STATUS - ), - "Github API Calls Remaining": rate_limit.get("remaining", "0"), - "Installed Version": client.version, - "Stage": client.stage, - "Available Repositories": len(client.repositories), - "Installed Repositories": len( - [repo for repo in client.repositories if repo.data.installed] - ), + hacs: HacsBase = hass.data[DOMAIN] + response = await hacs.githubapi.rate_limit() + + data = { + "GitHub API": system_health.async_check_can_reach_url(hass, BASE_API_URL, GITHUB_STATUS), + "Github API Calls Remaining": response.data.resources.core.remaining, + "Installed Version": hacs.version, + "Stage": hacs.stage, + "Available Repositories": len(hacs.repositories), + "Installed Repositories": len([repo for repo in hacs.repositories if repo.data.installed]), } + + if hacs.system.disabled: + data["Disabled"] = hacs.system.disabled_reason + + return data diff --git a/custom_components/hacs/tasks/__init__.py b/custom_components/hacs/tasks/__init__.py new file mode 100644 index 00000000..db567c4a --- /dev/null +++ b/custom_components/hacs/tasks/__init__.py @@ -0,0 +1 @@ +"""Init HACS tasks.""" diff --git a/custom_components/hacs/tasks/activate_categories.py b/custom_components/hacs/tasks/activate_categories.py new file mode 100644 index 00000000..07a6bbec --- /dev/null +++ b/custom_components/hacs/tasks/activate_categories.py @@ -0,0 +1,35 @@ +"""Starting setup task: extra stores.""" +from __future__ import annotations + +from homeassistant.core import HomeAssistant + +from ..base import HacsBase +from ..enums import HacsCategory, HacsStage +from .base import HacsTask + + +async def async_setup_task(hacs: HacsBase, hass: HomeAssistant) -> Task: + """Set up this task.""" + return Task(hacs=hacs, hass=hass) + + +class Task(HacsTask): + """Set up extra stores in HACS if enabled in Home Assistant.""" + + stages = [HacsStage.SETUP] + + def execute(self) -> None: + self.hacs.common.categories = set() + for category in (HacsCategory.INTEGRATION, HacsCategory.PLUGIN): + self.hacs.enable_hacs_category(HacsCategory(category)) + + if HacsCategory.PYTHON_SCRIPT in self.hacs.hass.config.components: + self.hacs.enable_hacs_category(HacsCategory.PYTHON_SCRIPT) + + if self.hacs.hass.services.has_service("frontend", "reload_themes"): + self.hacs.enable_hacs_category(HacsCategory.THEME) + + if self.hacs.configuration.appdaemon: + self.hacs.enable_hacs_category(HacsCategory.APPDAEMON) + if self.hacs.configuration.netdaemon: + self.hacs.enable_hacs_category(HacsCategory.NETDAEMON) diff --git a/custom_components/hacs/tasks/base.py b/custom_components/hacs/tasks/base.py new file mode 100644 index 00000000..fc7d9b17 --- /dev/null +++ b/custom_components/hacs/tasks/base.py @@ -0,0 +1,58 @@ +""""Hacs base setup task.""" +# pylint: disable=abstract-method +from __future__ import annotations + +from datetime import timedelta +from timeit import default_timer as timer + +from homeassistant.core import HomeAssistant + +from ..base import HacsBase +from ..enums import HacsStage +from ..mixin import LogMixin + + +class HacsTask(LogMixin): + """Hacs task base.""" + + hass: HomeAssistant + + events: list[str] | None = None + schedule: timedelta | None = None + stages: list[HacsStage] | None = None + + def __init__(self, hacs: HacsBase, hass: HomeAssistant) -> None: + self.hacs = hacs + self.hass = hass + + @property + def slug(self) -> str: + """Return the check slug.""" + return self.__class__.__module__.rsplit(".", maxsplit=1)[-1] + + async def execute_task(self, *_, **__) -> None: + """Execute the task defined in subclass.""" + if self.hacs.system.disabled: + self.log.warning( + "Skipping task %s, HACS is disabled - %s", + self.slug, + self.hacs.system.disabled_reason, + ) + return + self.log.info("Executing task: %s", self.slug) + start_time = timer() + + try: + if task := getattr(self, "execute", None): + await self.hass.async_add_executor_job(task) + elif task := getattr(self, "async_execute", None): + await task() # pylint: disable=not-callable + except BaseException as exception: # pylint: disable=broad-except + self.log.error("Task %s failed: %s", self.slug, exception) + + else: + self.log.debug( + "Task %s took " "%.2f seconds to complete", + self.slug, + timer() - start_time, + ) diff --git a/custom_components/hacs/tasks/check_constrains.py b/custom_components/hacs/tasks/check_constrains.py new file mode 100644 index 00000000..aca0fc17 --- /dev/null +++ b/custom_components/hacs/tasks/check_constrains.py @@ -0,0 +1,43 @@ +""""Starting setup task: Constrains".""" +from __future__ import annotations + +import os + +from homeassistant.core import HomeAssistant + +from ..base import HacsBase +from ..const import MINIMUM_HA_VERSION +from ..enums import HacsDisabledReason, HacsStage +from ..utils.version import version_left_higher_then_right +from .base import HacsTask + + +async def async_setup_task(hacs: HacsBase, hass: HomeAssistant) -> Task: + """Set up this task.""" + return Task(hacs=hacs, hass=hass) + + +class Task(HacsTask): + """Check env Constrains.""" + + stages = [HacsStage.SETUP] + + def execute(self) -> None: + for location in ( + self.hass.config.path("custom_components/custom_updater.py"), + self.hass.config.path("custom_components/custom_updater/__init__.py"), + ): + if os.path.exists(location): + self.log.critical( + "This cannot be used with custom_updater. " + "To use this you need to remove custom_updater form %s", + location, + ) + self.hacs.disable_hacs(HacsDisabledReason.CONSTRAINS) + + if not version_left_higher_then_right(self.hacs.core.ha_version, MINIMUM_HA_VERSION): + self.log.critical( + "You need HA version %s or newer to use this integration.", + MINIMUM_HA_VERSION, + ) + self.hacs.disable_hacs(HacsDisabledReason.CONSTRAINS) diff --git a/custom_components/hacs/tasks/clear_old_storage.py b/custom_components/hacs/tasks/clear_old_storage.py new file mode 100644 index 00000000..20250cb8 --- /dev/null +++ b/custom_components/hacs/tasks/clear_old_storage.py @@ -0,0 +1,28 @@ +"""Starting setup task: clear storage.""" +from __future__ import annotations + +import os + +from homeassistant.core import HomeAssistant + +from ..base import HacsBase +from ..enums import HacsStage +from .base import HacsTask + + +async def async_setup_task(hacs: HacsBase, hass: HomeAssistant) -> Task: + """Set up this task.""" + return Task(hacs=hacs, hass=hass) + + +class Task(HacsTask): + """Clear old files from storage.""" + + stages = [HacsStage.SETUP] + + def execute(self) -> None: + for storage_file in ("hacs",): + path = f"{self.hacs.core.config_path}/.storage/{storage_file}" + if os.path.isfile(path): + self.log.info("Cleaning up old storage file: %s", path) + os.remove(path) diff --git a/custom_components/hacs/tasks/hello_world.py b/custom_components/hacs/tasks/hello_world.py new file mode 100644 index 00000000..c4b9f720 --- /dev/null +++ b/custom_components/hacs/tasks/hello_world.py @@ -0,0 +1,23 @@ +""""Hacs base setup task.""" +from __future__ import annotations + +from datetime import timedelta + +from homeassistant.core import HomeAssistant + +from ..base import HacsBase +from .base import HacsTask + + +async def async_setup_task(hacs: HacsBase, hass: HomeAssistant) -> Task: + """Set up this task.""" + return Task(hacs=hacs, hass=hass) + + +class Task(HacsTask): + """ "Hacs task base.""" + + schedule = timedelta(weeks=52) + + def execute(self) -> None: + self.log.debug("Hello World!") diff --git a/custom_components/hacs/tasks/load_hacs_repository.py b/custom_components/hacs/tasks/load_hacs_repository.py new file mode 100644 index 00000000..45956232 --- /dev/null +++ b/custom_components/hacs/tasks/load_hacs_repository.py @@ -0,0 +1,40 @@ +"""Starting setup task: load HACS repository.""" +from __future__ import annotations + +from homeassistant.core import HomeAssistant + +from ..base import HacsBase +from ..enums import HacsDisabledReason, HacsStage +from ..exceptions import HacsException +from ..helpers.functions.register_repository import register_repository +from .base import HacsTask + + +async def async_setup_task(hacs: HacsBase, hass: HomeAssistant) -> Task: + """Set up this task.""" + return Task(hacs=hacs, hass=hass) + + +class Task(HacsTask): + """Load HACS repositroy.""" + + stages = [HacsStage.STARTUP] + + async def async_execute(self) -> None: + try: + repository = self.hacs.get_by_name("hacs/integration") + if repository is None: + await register_repository("hacs/integration", "integration") + repository = self.hacs.get_by_name("hacs/integration") + if repository is None: + raise HacsException("Unknown error") + repository.data.installed = True + repository.data.installed_version = self.hacs.integration.version + repository.data.new = False + self.hacs.repository = repository.repository_object + except HacsException as exception: + if "403" in f"{exception}": + self.log.critical("GitHub API is ratelimited, or the token is wrong.") + else: + self.log.critical("[%s] - Could not load HACS!", exception) + self.hacs.disable_hacs(HacsDisabledReason.LOAD_HACS) diff --git a/custom_components/hacs/tasks/manager.py b/custom_components/hacs/tasks/manager.py new file mode 100644 index 00000000..3f17fa9b --- /dev/null +++ b/custom_components/hacs/tasks/manager.py @@ -0,0 +1,75 @@ +"""Hacs task manager.""" +from __future__ import annotations + +import asyncio +from importlib import import_module +from pathlib import Path + +from homeassistant.core import HomeAssistant + +from ..base import HacsBase +from ..mixin import LogMixin +from .base import HacsTask + + +class HacsTaskManager(LogMixin): + """Hacs task manager.""" + + def __init__(self, hacs: HacsBase, hass: HomeAssistant) -> None: + """Initialize the setup manager class.""" + self.hacs = hacs + self.hass = hass + self.__tasks: dict[str, HacsTask] = {} + + @property + def tasks(self) -> list[HacsTask]: + """Return all list of all tasks.""" + return list(self.__tasks.values()) + + async def async_load(self) -> None: + """Load all tasks.""" + task_files = Path(__file__).parent + task_modules = ( + module.stem + for module in task_files.glob("*.py") + if module.name not in ("base.py", "__init__.py", "manager.py") + ) + + async def _load_module(module: str): + task_module = import_module(f"{__package__}.{module}") + if task := await task_module.async_setup_task(hacs=self.hacs, hass=self.hass): + self.__tasks[task.slug] = task + + await asyncio.gather(*[_load_module(task) for task in task_modules]) + self.log.info("Loaded %s tasks", len(self.tasks)) + + schedule_tasks = len(self.hacs.recuring_tasks) == 0 + + for task in self.tasks: + if task.events is not None: + for event in task.events: + self.hass.bus.async_listen_once(event, task.execute_task) + + if task.schedule is not None and schedule_tasks: + self.log.debug("Scheduling the %s task to run every %s", task.slug, task.schedule) + self.hacs.recuring_tasks.append( + self.hacs.hass.helpers.event.async_track_time_interval( + task.execute_task, task.schedule + ) + ) + + def get(self, slug: str) -> HacsTask | None: + """Return a task.""" + return self.__tasks.get(slug) + + async def async_execute_runtume_tasks(self) -> None: + """Execute the the execute methods of each runtime task if the stage matches.""" + self.hacs.status.background_task = True + await asyncio.gather( + *( + task.execute_task() + for task in self.tasks + if task.stages is not None and self.hacs.stage in task.stages + ) + ) + self.hacs.status.background_task = False diff --git a/custom_components/hacs/tasks/restore_data.py b/custom_components/hacs/tasks/restore_data.py new file mode 100644 index 00000000..e653d7c0 --- /dev/null +++ b/custom_components/hacs/tasks/restore_data.py @@ -0,0 +1,23 @@ +""""Starting setup task: Restore".""" +from __future__ import annotations + +from homeassistant.core import HomeAssistant + +from ..base import HacsBase +from ..enums import HacsDisabledReason, HacsStage +from .base import HacsTask + + +async def async_setup_task(hacs: HacsBase, hass: HomeAssistant) -> Task: + """Set up this task.""" + return Task(hacs=hacs, hass=hass) + + +class Task(HacsTask): + """Restore HACS data.""" + + stages = [HacsStage.SETUP] + + async def async_execute(self) -> None: + if not await self.hacs.data.restore(): + self.hacs.disable_hacs(HacsDisabledReason.RESTORE) diff --git a/custom_components/hacs/tasks/setup_frontend.py b/custom_components/hacs/tasks/setup_frontend.py new file mode 100644 index 00000000..cbad9587 --- /dev/null +++ b/custom_components/hacs/tasks/setup_frontend.py @@ -0,0 +1,87 @@ +""""Starting setup task: Frontend".""" +from __future__ import annotations + +from hacs_frontend import locate_dir +from hacs_frontend.version import VERSION as FE_VERSION + +from ..const import DOMAIN +from ..enums import HacsStage +from ..webresponses.frontend import HacsFrontendDev +from .base import HacsTask + +URL_BASE = "/hacsfiles" + + +from homeassistant.core import HomeAssistant + +from ..base import HacsBase + + +async def async_setup_task(hacs: HacsBase, hass: HomeAssistant) -> Task: + """Set up this task.""" + return Task(hacs=hacs, hass=hass) + + +class Task(HacsTask): + """Setup the HACS frontend.""" + + stages = [HacsStage.SETUP] + + def execute(self) -> None: + + # Register themes + self.hass.http.register_static_path(f"{URL_BASE}/themes", self.hass.config.path("themes")) + + # Register frontend + if self.hacs.configuration.frontend_repo_url: + self.log.warning("Frontend development mode enabled. Do not run in production!") + self.hass.http.register_view(HacsFrontendDev()) + else: + # + self.hass.http.register_static_path( + f"{URL_BASE}/frontend", locate_dir(), cache_headers=False + ) + + # Custom iconset + self.hass.http.register_static_path( + f"{URL_BASE}/iconset.js", str(self.hacs.integration_dir / "iconset.js") + ) + if "frontend_extra_module_url" not in self.hass.data: + self.hass.data["frontend_extra_module_url"] = set() + self.hass.data["frontend_extra_module_url"].add("/hacsfiles/iconset.js") + + # Register www/community for all other files + use_cache = self.hacs.core.lovelace_mode == "storage" + self.log.info( + "%s mode, cache for /hacsfiles/: %s", + self.hacs.core.lovelace_mode, + use_cache, + ) + self.hass.http.register_static_path( + URL_BASE, + self.hass.config.path("www/community"), + cache_headers=use_cache, + ) + + self.hacs.frontend.version_running = FE_VERSION + for requirement in self.hacs.integration.requirements: + if "hacs_frontend" in requirement: + self.hacs.frontend.version_expected = requirement.split("==")[-1] + + # Add to sidepanel if needed + if DOMAIN not in self.hass.data.get("frontend_panels", {}): + self.hass.components.frontend.async_register_built_in_panel( + component_name="custom", + sidebar_title=self.hacs.configuration.sidepanel_title, + sidebar_icon=self.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={FE_VERSION}", + } + }, + require_admin=True, + ) diff --git a/custom_components/hacs/tasks/setup_sensor.py b/custom_components/hacs/tasks/setup_sensor.py new file mode 100644 index 00000000..fa43bb38 --- /dev/null +++ b/custom_components/hacs/tasks/setup_sensor.py @@ -0,0 +1,31 @@ +""""Starting setup task: Sensor".""" +from __future__ import annotations + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.discovery import async_load_platform + +from ..base import HacsBase +from ..const import DOMAIN, PLATFORMS +from ..enums import ConfigurationType, HacsStage +from .base import HacsTask + + +async def async_setup_task(hacs: HacsBase, hass: HomeAssistant) -> Task: + """Set up this task.""" + return Task(hacs=hacs, hass=hass) + + +class Task(HacsTask): + """Setup the HACS sensor platform.""" + + stages = [HacsStage.SETUP] + + async def async_execute(self) -> None: + if self.hacs.configuration.config_type == ConfigurationType.YAML: + self.hass.async_create_task( + async_load_platform(self.hass, "sensor", DOMAIN, {}, self.hacs.configuration.config) + ) + else: + self.hass.config_entries.async_setup_platforms( + self.hacs.configuration.config_entry, PLATFORMS + ) diff --git a/custom_components/hacs/tasks/setup_websocket_api.py b/custom_components/hacs/tasks/setup_websocket_api.py new file mode 100644 index 00000000..f776c10e --- /dev/null +++ b/custom_components/hacs/tasks/setup_websocket_api.py @@ -0,0 +1,42 @@ +"""Register WS API endpoints for HACS.""" +from __future__ import annotations + +from homeassistant.components.websocket_api import async_register_command +from homeassistant.core import HomeAssistant + +from ..api.acknowledge_critical_repository import acknowledge_critical_repository +from ..api.check_local_path import check_local_path +from ..api.get_critical_repositories import get_critical_repositories +from ..api.hacs_config import hacs_config +from ..api.hacs_removed import hacs_removed +from ..api.hacs_repositories import hacs_repositories +from ..api.hacs_repository import hacs_repository +from ..api.hacs_repository_data import hacs_repository_data +from ..api.hacs_settings import hacs_settings +from ..api.hacs_status import hacs_status +from ..base import HacsBase +from ..enums import HacsStage +from .base import HacsTask + + +async def async_setup_task(hacs: HacsBase, hass: HomeAssistant) -> Task: + """Set up this task.""" + return Task(hacs=hacs, hass=hass) + + +class Task(HacsTask): + """Setup the HACS websocket API.""" + + stages = [HacsStage.SETUP] + + async def async_execute(self) -> None: + async_register_command(self.hass, hacs_settings) + async_register_command(self.hass, hacs_config) + async_register_command(self.hass, hacs_repositories) + async_register_command(self.hass, hacs_repository) + async_register_command(self.hass, hacs_repository_data) + async_register_command(self.hass, check_local_path) + async_register_command(self.hass, hacs_status) + async_register_command(self.hass, hacs_removed) + async_register_command(self.hass, acknowledge_critical_repository) + async_register_command(self.hass, get_critical_repositories) diff --git a/custom_components/hacs/tasks/store_hacs_data.py b/custom_components/hacs/tasks/store_hacs_data.py new file mode 100644 index 00000000..ccabd4a5 --- /dev/null +++ b/custom_components/hacs/tasks/store_hacs_data.py @@ -0,0 +1,22 @@ +""""Store HACS data.""" +from __future__ import annotations + +from homeassistant.const import EVENT_HOMEASSISTANT_FINAL_WRITE +from homeassistant.core import HomeAssistant + +from ..base import HacsBase +from .base import HacsTask + + +async def async_setup_task(hacs: HacsBase, hass: HomeAssistant) -> Task: + """Set up this task.""" + return Task(hacs=hacs, hass=hass) + + +class Task(HacsTask): + """ "Hacs task base.""" + + events = [EVENT_HOMEASSISTANT_FINAL_WRITE] + + async def async_execute(self) -> None: + await self.hacs.data.async_write() diff --git a/custom_components/hacs/tasks/verify_api.py b/custom_components/hacs/tasks/verify_api.py new file mode 100644 index 00000000..02b2ae35 --- /dev/null +++ b/custom_components/hacs/tasks/verify_api.py @@ -0,0 +1,23 @@ +""""Starting setup task: Verify API".""" +from __future__ import annotations + +from homeassistant.core import HomeAssistant + +from ..base import HacsBase +from ..enums import HacsStage +from .base import HacsTask + + +async def async_setup_task(hacs: HacsBase, hass: HomeAssistant) -> Task: + """Set up this task.""" + return Task(hacs=hacs, hass=hass) + + +class Task(HacsTask): + """Verify the connection to the GitHub API.""" + + stages = [HacsStage.SETUP] + + async def async_execute(self) -> None: + can_update = await self.hacs.async_can_update() + self.log.debug("Can update %s repositories", can_update) diff --git a/custom_components/hacs/translations/en.json b/custom_components/hacs/translations/en.json index 7ed7b446..12c6c07b 100644 --- a/custom_components/hacs/translations/en.json +++ b/custom_components/hacs/translations/en.json @@ -30,6 +30,10 @@ } }, "options": { + "abort": { + "not_setup": "HACS is not setup.", + "release_limit_value": "The release limit needs to be between 1 and 100" + }, "step": { "user": { "data": { diff --git a/custom_components/hacs/utils/__init__.py b/custom_components/hacs/utils/__init__.py new file mode 100644 index 00000000..58b214ca --- /dev/null +++ b/custom_components/hacs/utils/__init__.py @@ -0,0 +1 @@ +"""Initialize HACS utils.""" diff --git a/custom_components/hacs/utils/decode.py b/custom_components/hacs/utils/decode.py new file mode 100644 index 00000000..9d42516c --- /dev/null +++ b/custom_components/hacs/utils/decode.py @@ -0,0 +1,7 @@ +"""Util to decode content from the github API.""" +from base64 import b64decode + + +def decode_content(content: str) -> str: + """Decode content.""" + return b64decode(bytearray(content, "utf-8")).decode() diff --git a/custom_components/hacs/helpers/functions/logger.py b/custom_components/hacs/utils/logger.py similarity index 92% rename from custom_components/hacs/helpers/functions/logger.py rename to custom_components/hacs/utils/logger.py index 900bc916..c3fc4dd7 100644 --- a/custom_components/hacs/helpers/functions/logger.py +++ b/custom_components/hacs/utils/logger.py @@ -3,7 +3,7 @@ import logging import os -from ...const import PACKAGE_NAME +from ..const import PACKAGE_NAME _HACSLogger: logging.Logger = logging.getLogger(PACKAGE_NAME) diff --git a/custom_components/hacs/utils/path.py b/custom_components/hacs/utils/path.py new file mode 100644 index 00000000..3e10e47c --- /dev/null +++ b/custom_components/hacs/utils/path.py @@ -0,0 +1,21 @@ +"""Path utils""" +from __future__ import annotations + +from pathlib import Path +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from ..hacsbase.hacs import Hacs + + +def is_safe(hacs: Hacs, path: str | Path) -> bool: + """Helper to check if path is safe to remove.""" + paths = [ + Path(f"{hacs.core.config_path}/{hacs.configuration.appdaemon_path}"), + Path(f"{hacs.core.config_path}/{hacs.configuration.netdaemon_path}"), + Path(f"{hacs.core.config_path}/{hacs.configuration.plugin_path}"), + Path(f"{hacs.core.config_path}/{hacs.configuration.python_script_path}"), + Path(f"{hacs.core.config_path}/{hacs.configuration.theme_path}"), + Path(f"{hacs.core.config_path}/custom_components/"), + ] + return Path(path) not in paths diff --git a/custom_components/hacs/utils/version.py b/custom_components/hacs/utils/version.py new file mode 100644 index 00000000..77f28fa5 --- /dev/null +++ b/custom_components/hacs/utils/version.py @@ -0,0 +1,15 @@ +"""Version utils.""" + + +from functools import lru_cache + +from awesomeversion import AwesomeVersionException, AwesomeVersion + + +@lru_cache(maxsize=1024) +def version_left_higher_then_right(left: str, right: str) -> bool: + """Return a bool if source is newer than target, will also be true if identical.""" + try: + return AwesomeVersion(left) >= AwesomeVersion(right) + except AwesomeVersionException: + return False diff --git a/custom_components/hacs/webresponses/frontend.py b/custom_components/hacs/webresponses/frontend.py index b1a32f9b..8b29c9b0 100644 --- a/custom_components/hacs/webresponses/frontend.py +++ b/custom_components/hacs/webresponses/frontend.py @@ -1,6 +1,6 @@ from aiohttp import web - from homeassistant.components.http import HomeAssistantView + from custom_components.hacs.share import get_hacs @@ -15,9 +15,7 @@ async def get(self, request, requested_file): # pylint: disable=unused-argument """Handle HACS Web requests.""" hacs = get_hacs() requested = requested_file.split("/")[-1] - request = await hacs.session.get( - f"{hacs.configuration.frontend_repo_url}/{requested}" - ) + request = await hacs.session.get(f"{hacs.configuration.frontend_repo_url}/{requested}") if request.status == 200: result = await request.read() response = web.Response(body=result) diff --git a/custom_components/here_weather/__init__.py b/custom_components/here_weather/__init__.py new file mode 100644 index 00000000..8ecd8b9f --- /dev/null +++ b/custom_components/here_weather/__init__.py @@ -0,0 +1,111 @@ +"""The here_weather component.""" +from __future__ import annotations + +from datetime import timedelta +import logging +from typing import Any + +import aiohere +import async_timeout +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + CONF_API_KEY, + CONF_LATITUDE, + CONF_LONGITUDE, + CONF_UNIT_SYSTEM_METRIC, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import CONF_MODES, DEFAULT_SCAN_INTERVAL, DOMAIN, STARTUP_MESSAGE + +_LOGGER = logging.getLogger(__name__) + +PLATFORMS = ["sensor", "weather"] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up here_weather from a config entry.""" + if hass.data.get(DOMAIN) is None: + _LOGGER.info(STARTUP_MESSAGE) + here_weather_coordinators = {} + for mode in CONF_MODES: + coordinator = HEREWeatherDataUpdateCoordinator(hass, entry, mode) + await coordinator.async_config_entry_first_refresh() + here_weather_coordinators[mode] = coordinator + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = here_weather_coordinators + + hass.config_entries.async_setup_platforms(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + if unload_ok: + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok + + +class HEREWeatherDataUpdateCoordinator(DataUpdateCoordinator): + """Get the latest data from HERE.""" + + def __init__(self, hass: HomeAssistant, entry: ConfigEntry, mode: str) -> None: + """Initialize the data object.""" + session = async_get_clientsession(hass) + self.here_client = aiohere.AioHere(entry.data[CONF_API_KEY], session=session) + self.latitude = entry.data[CONF_LATITUDE] + self.longitude = entry.data[CONF_LONGITUDE] + self.weather_product_type = aiohere.WeatherProductType[mode] + + super().__init__( + hass, + _LOGGER, + name=DOMAIN, + update_interval=timedelta(seconds=DEFAULT_SCAN_INTERVAL), + ) + + async def _async_update_data(self) -> list: + """Perform data update.""" + try: + async with async_timeout.timeout(10): + data = await self._get_data() + return data + except aiohere.HereError as error: + raise UpdateFailed( + f"Unable to fetch data from HERE: {error.args[0]}" + ) from error + + async def _get_data(self): + """Get the latest data from HERE.""" + is_metric = self.hass.config.units.name == CONF_UNIT_SYSTEM_METRIC + data = await self.here_client.weather_for_coordinates( + self.latitude, + self.longitude, + self.weather_product_type, + metric=is_metric, + ) + return extract_data_from_payload_for_product_type( + data, self.weather_product_type + ) + + +def extract_data_from_payload_for_product_type( + data: dict[str, Any], product_type: aiohere.WeatherProductType +) -> list: + """Extract the actual data from the HERE payload.""" + if product_type == aiohere.WeatherProductType.FORECAST_ASTRONOMY: + return data["astronomy"]["astronomy"] + if product_type == aiohere.WeatherProductType.OBSERVATION: + return data["observations"]["location"][0]["observation"] + if product_type == aiohere.WeatherProductType.FORECAST_7DAYS: + return data["forecasts"]["forecastLocation"]["forecast"] + if product_type == aiohere.WeatherProductType.FORECAST_7DAYS_SIMPLE: + return data["dailyForecasts"]["forecastLocation"]["forecast"] + if product_type == aiohere.WeatherProductType.FORECAST_HOURLY: + return data["hourlyForecasts"]["forecastLocation"]["forecast"] + _LOGGER.debug("Payload malformed: %s", data) + raise UpdateFailed("Payload malformed") diff --git a/custom_components/here_weather/config_flow.py b/custom_components/here_weather/config_flow.py new file mode 100644 index 00000000..7a00d2e0 --- /dev/null +++ b/custom_components/here_weather/config_flow.py @@ -0,0 +1,82 @@ +"""Config flow for here_weather integration.""" +from __future__ import annotations + +import aiohere +from homeassistant import config_entries +from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME +from homeassistant.core import HomeAssistant +from homeassistant.helpers.aiohttp_client import async_get_clientsession +import homeassistant.helpers.config_validation as cv +import voluptuous as vol + +from .const import DEFAULT_MODE, DOMAIN + + +async def async_validate_user_input(hass: HomeAssistant, user_input: dict) -> None: + """Validate the user_input containing coordinates.""" + session = async_get_clientsession(hass) + here_client = aiohere.AioHere(user_input[CONF_API_KEY], session=session) + await here_client.weather_for_coordinates( + user_input[CONF_LATITUDE], + user_input[CONF_LONGITUDE], + aiohere.WeatherProductType[DEFAULT_MODE], + ) + + +class HereWeatherConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for here_weather.""" + + VERSION = 1 + + async def async_step_user(self, user_input=None): + """Handle the initial step.""" + errors = {} + if user_input is not None: + await self.async_set_unique_id(_unique_id(user_input)) + self._abort_if_unique_id_configured() + try: + await async_validate_user_input(self.hass, user_input) + except aiohere.HereInvalidRequestError: + errors["base"] = "invalid_request" + except aiohere.HereUnauthorizedError: + errors["base"] = "unauthorized" + else: + return self.async_create_entry( + title=user_input[CONF_NAME], data=user_input + ) + return self.async_show_form( + step_id="user", + data_schema=self._get_schema(user_input), + errors=errors, + ) + + def _get_schema(self, user_input: dict | None) -> vol.Schema: + if user_input is not None: + return vol.Schema( + { + vol.Required(CONF_API_KEY, default=user_input[CONF_API_KEY]): str, + vol.Required(CONF_NAME, default=user_input[CONF_NAME]): str, + vol.Required( + CONF_LATITUDE, default=user_input[CONF_LATITUDE] + ): cv.latitude, + vol.Required( + CONF_LONGITUDE, default=user_input[CONF_LONGITUDE] + ): cv.longitude, + } + ) + return vol.Schema( + { + vol.Required(CONF_API_KEY): str, + vol.Required(CONF_NAME, default=DOMAIN): str, + vol.Required( + CONF_LATITUDE, default=self.hass.config.latitude + ): cv.latitude, + vol.Required( + CONF_LONGITUDE, default=self.hass.config.longitude + ): cv.longitude, + } + ) + + +def _unique_id(user_input: dict) -> str: + return f"{user_input[CONF_LATITUDE]}_{user_input[CONF_LONGITUDE]}" diff --git a/custom_components/here_weather/const.py b/custom_components/here_weather/const.py new file mode 100644 index 00000000..013372fd --- /dev/null +++ b/custom_components/here_weather/const.py @@ -0,0 +1,513 @@ +"""Constants for the HERE Destination Weather service.""" +from __future__ import annotations + +from homeassistant.const import ( + DEGREE, + DEVICE_CLASS_HUMIDITY, + DEVICE_CLASS_PRESSURE, + DEVICE_CLASS_TEMPERATURE, + DEVICE_CLASS_TIMESTAMP, + LENGTH_CENTIMETERS, + LENGTH_KILOMETERS, + PERCENTAGE, + PRESSURE_MBAR, + SPEED_KILOMETERS_PER_HOUR, + TEMP_CELSIUS, +) + +NAME = "HERE Destination Weather" +DOMAIN = "here_weather" +VERSION = "1.0.2" +ISSUE_URL = "https://github.com/eifinger/hass-here-weather/issues" + +STARTUP_MESSAGE = f""" +------------------------------------------------------------------- +{NAME} +Version: {VERSION} +This is a custom integration! +If you have any issues with this you need to open an issue here: +{ISSUE_URL} +------------------------------------------------------------------- +""" + +DEFAULT_SCAN_INTERVAL = 1800 # 30 minutes + +MODE_ASTRONOMY = "FORECAST_ASTRONOMY" +MODE_HOURLY = "FORECAST_HOURLY" +MODE_DAILY = "FORECAST_7DAYS" +MODE_DAILY_SIMPLE = "FORECAST_7DAYS_SIMPLE" +MODE_OBSERVATION = "OBSERVATION" +CONF_MODES = [ + MODE_ASTRONOMY, + MODE_HOURLY, + MODE_DAILY, + MODE_DAILY_SIMPLE, + MODE_OBSERVATION, +] +DEFAULT_MODE = MODE_DAILY_SIMPLE + +ASTRONOMY_ATTRIBUTES: dict[str, dict[str, str | None]] = { + "sunrise": {"name": "Sunrise", "unit_of_measurement": None, "device_class": None}, + "sunset": {"name": "Sunset", "unit_of_measurement": None, "device_class": None}, + "moonrise": {"name": "Moonrise", "unit_of_measurement": None, "device_class": None}, + "moonset": {"name": "Moonset", "unit_of_measurement": None, "device_class": None}, + "moonPhase": { + "name": "Moon Phase", + "unit_of_measurement": PERCENTAGE, + "device_class": None, + }, + "moonPhaseDesc": { + "name": "Moon Phase Description", + "unit_of_measurement": None, + "device_class": None, + }, + "city": {"name": "City", "unit_of_measurement": None, "device_class": None}, + "latitude": {"name": "Latitude", "unit_of_measurement": None, "device_class": None}, + "longitude": { + "name": "Longitude", + "unit_of_measurement": None, + "device_class": None, + }, + "utcTime": { + "name": "UTC Time", + "unit_of_measurement": None, + "device_class": DEVICE_CLASS_TIMESTAMP, + }, +} + +COMMON_ATTRIBUTES: dict[str, dict[str, str | None]] = { + "daylight": {"name": "Daylight", "unit_of_measurement": None, "device_class": None}, + "description": { + "name": "Description", + "unit_of_measurement": None, + "device_class": None, + }, + "skyInfo": {"name": "Sky Info", "unit_of_measurement": None, "device_class": None}, + "skyDescription": { + "name": "Sky Description", + "unit_of_measurement": None, + "device_class": None, + }, + "temperatureDesc": { + "name": "Temperature Description", + "unit_of_measurement": None, + "device_class": None, + }, + "comfort": { + "name": "Comfort", + "unit_of_measurement": TEMP_CELSIUS, + "device_class": DEVICE_CLASS_TEMPERATURE, + }, + "humidity": { + "name": "Humidity", + "unit_of_measurement": PERCENTAGE, + "device_class": DEVICE_CLASS_HUMIDITY, + }, + "dewPoint": { + "name": "Dew Point", + "unit_of_measurement": TEMP_CELSIUS, + "device_class": DEVICE_CLASS_TEMPERATURE, + }, + "precipitationProbability": { + "name": "Precipitation Probability", + "unit_of_measurement": PERCENTAGE, + "device_class": None, + }, + "precipitationDesc": { + "name": "Precipitation Description", + "unit_of_measurement": None, + "device_class": None, + }, + "airDescription": { + "name": "Air Description", + "unit_of_measurement": None, + "device_class": None, + }, + "windSpeed": { + "name": "Wind Speed", + "unit_of_measurement": SPEED_KILOMETERS_PER_HOUR, + "device_class": None, + }, + "windDirection": { + "name": "Wind Direction", + "unit_of_measurement": DEGREE, + "device_class": None, + }, + "windDesc": { + "name": "Wind Description", + "unit_of_measurement": LENGTH_CENTIMETERS, + "device_class": None, + }, + "windDescShort": { + "name": "Wind Description Short", + "unit_of_measurement": LENGTH_CENTIMETERS, + "device_class": None, + }, + "icon": {"name": "Icon", "unit_of_measurement": None, "device_class": None}, + "iconName": { + "name": "Icon Name", + "unit_of_measurement": None, + "device_class": None, + }, + "iconLink": { + "name": "Icon Link", + "unit_of_measurement": None, + "device_class": None, + }, +} + +NON_OBSERVATION_ATTRIBUTES: dict[str, dict[str, str | None]] = { + "rainFall": { + "name": "Rain Fall", + "unit_of_measurement": LENGTH_CENTIMETERS, + "device_class": None, + }, + "snowFall": { + "name": "Snow Fall", + "unit_of_measurement": LENGTH_CENTIMETERS, + "device_class": None, + }, + "precipitationProbability": { + "name": "Precipitation Probability", + "unit_of_measurement": PERCENTAGE, + "device_class": None, + }, + "dayOfWeek": { + "name": "Day of Week", + "unit_of_measurement": None, + "device_class": None, + }, + "weekday": {"name": "Week Day", "unit_of_measurement": None, "device_class": None}, + "utcTime": { + "name": "UTC Time", + "unit_of_measurement": None, + "device_class": DEVICE_CLASS_TIMESTAMP, + }, + **COMMON_ATTRIBUTES, +} + +HOURLY_ATTRIBUTES: dict[str, dict[str, str | None]] = { + "temperature": { + "name": "Temperature", + "unit_of_measurement": TEMP_CELSIUS, + "device_class": DEVICE_CLASS_TEMPERATURE, + }, + "airInfo": {"name": "Air Info", "unit_of_measurement": None, "device_class": None}, + "visibility": { + "name": "Visibility", + "unit_of_measurement": LENGTH_KILOMETERS, + "device_class": None, + }, + **NON_OBSERVATION_ATTRIBUTES, +} + +DAILY_SIMPLE_ATTRIBUTES: dict[str, dict[str, str | None]] = { + "highTemperature": { + "name": "High Temperature", + "unit_of_measurement": TEMP_CELSIUS, + "device_class": DEVICE_CLASS_TEMPERATURE, + }, + "lowTemperature": { + "name": "Low Temperature", + "unit_of_measurement": TEMP_CELSIUS, + "device_class": DEVICE_CLASS_TEMPERATURE, + }, + "beaufortScale": { + "name": "Beaufort Scale", + "unit_of_measurement": None, + "device_class": None, + }, + "beaufortDescription": { + "name": "Beaufort Scale Description", + "unit_of_measurement": None, + "device_class": None, + }, + "uvIndex": {"name": "UV Index", "unit_of_measurement": None, "device_class": None}, + "uvDesc": { + "name": "UV Index Description", + "unit_of_measurement": None, + "device_class": None, + }, + "barometerPressure": { + "name": "Barometric Pressure", + "unit_of_measurement": PRESSURE_MBAR, + "device_class": DEVICE_CLASS_PRESSURE, + }, + **NON_OBSERVATION_ATTRIBUTES, +} + +DAILY_ATTRIBUTES: dict[str, dict[str, str | None]] = { + "daySegment": { + "name": "Day Segment", + "unit_of_measurement": None, + "device_class": None, + }, + "temperature": { + "name": "Temperature", + "unit_of_measurement": TEMP_CELSIUS, + "device_class": DEVICE_CLASS_TEMPERATURE, + }, + "beaufortScale": { + "name": "Beaufort Scale", + "unit_of_measurement": None, + "device_class": None, + }, + "beaufortDescription": { + "name": "Beaufort Scale Description", + "unit_of_measurement": None, + "device_class": None, + }, + "visibility": { + "name": "Visibility", + "unit_of_measurement": LENGTH_KILOMETERS, + "device_class": None, + }, + **NON_OBSERVATION_ATTRIBUTES, +} + +OBSERVATION_ATTRIBUTES: dict[str, dict[str, str | None]] = { + "temperature": { + "name": "Temperature", + "unit_of_measurement": TEMP_CELSIUS, + "device_class": DEVICE_CLASS_TEMPERATURE, + }, + "highTemperature": { + "name": "High Temperature", + "unit_of_measurement": TEMP_CELSIUS, + "device_class": DEVICE_CLASS_TEMPERATURE, + }, + "lowTemperature": { + "name": "Low Temperature", + "unit_of_measurement": TEMP_CELSIUS, + "device_class": DEVICE_CLASS_TEMPERATURE, + }, + "precipitation1H": { + "name": "Precipitation Over 1 Hour", + "unit_of_measurement": LENGTH_CENTIMETERS, + "device_class": None, + }, + "precipitation3H": { + "name": "Precipitation Over 3 Hours", + "unit_of_measurement": LENGTH_CENTIMETERS, + "device_class": None, + }, + "precipitation6H": { + "name": "Precipitation Over 6 Hours", + "unit_of_measurement": LENGTH_CENTIMETERS, + "device_class": None, + }, + "precipitation12H": { + "name": "Precipitation Over 12 Hours", + "unit_of_measurement": LENGTH_CENTIMETERS, + "device_class": None, + }, + "precipitation24H": { + "name": "Precipitation Over 24 Hours", + "unit_of_measurement": LENGTH_CENTIMETERS, + "device_class": None, + }, + "barometerPressure": { + "name": "Barometric Pressure", + "unit_of_measurement": PRESSURE_MBAR, + "device_class": DEVICE_CLASS_PRESSURE, + }, + "barometerTrend": { + "name": "Barometric Pressure Trend", + "unit_of_measurement": None, + "device_class": None, + }, + "visibility": { + "name": "Visibility", + "unit_of_measurement": LENGTH_KILOMETERS, + "device_class": None, + }, + "snowCover": { + "name": "Snow Cover", + "unit_of_measurement": LENGTH_CENTIMETERS, + "device_class": None, + }, + "activeAlerts": { + "name": "Active Alerts", + "unit_of_measurement": None, + "device_class": None, + }, + "country": {"name": "Country", "unit_of_measurement": None, "device_class": None}, + "state": {"name": "State", "unit_of_measurement": None, "device_class": None}, + "city": {"name": "City", "unit_of_measurement": None, "device_class": None}, + **COMMON_ATTRIBUTES, +} + +SENSOR_TYPES: dict[str, dict[str, dict[str, str | None]]] = { + MODE_ASTRONOMY: ASTRONOMY_ATTRIBUTES, + MODE_HOURLY: HOURLY_ATTRIBUTES, + MODE_DAILY_SIMPLE: DAILY_SIMPLE_ATTRIBUTES, + MODE_DAILY: DAILY_ATTRIBUTES, + MODE_OBSERVATION: OBSERVATION_ATTRIBUTES, +} + +CONDITION_CLASSES: dict[str, list[str]] = { + "clear-night": [ + "night_passing_clouds", + "night_mostly_clear", + "night_clearing_skies", + "night_clear", + ], + "cloudy": [ + "night_decreasing_cloudiness", + "night_mostly_cloudy", + "night_morning_clouds", + "night_afternoon_clouds", + "night_high_clouds", + "night_high_level_clouds", + "low_clouds", + "overcast", + "cloudy", + "afternoon_clouds", + "morning_clouds", + "high_level_clouds", + "high_clouds", + ], + "fog": [ + "night_low_level_haze", + "night_smoke", + "night_haze", + "dense_fog", + "fog", + "light_fog", + "early_fog", + "early_fog_followed_by_sunny_skies", + "low_level_haze", + "smoke", + "haze", + "ice_fog", + "hazy_sunshine", + ], + "hail": ["hail"], + "lightning": [ + "night_tstorms", + "night_scattered_tstorms", + "scattered_tstorms", + "night_a_few_tstorms", + "night_isolated_tstorms", + "night_widely_scattered_tstorms", + "tstorms_early", + "isolated_tstorms_late", + "scattered_tstorms_late", + "widely_scattered_tstorms", + "isolated_tstorms", + "a_few_tstorms", + ], + "lightning-rainy": ["thundershowers", "thunderstorms", "tstorms_late", "tstorms"], + "partlycloudy": [ + "partly_sunny", + "mostly_cloudy", + "broken_clouds", + "more_clouds_than_sun", + "night_broken_clouds", + "increasing_cloudiness", + "night_partly_cloudy", + "night_scattered_clouds", + "passing_clounds", + "more_sun_than_clouds", + "scattered_clouds", + "partly_cloudy", + "a_mixture_of_sun_and_clouds", + "increasing_cloudiness", + "decreasing_cloudiness", + "clearing_skies", + "breaks_of_sun_late", + ], + "pouring": [ + "heavy_rain_late", + "heavy_rain_early", + "tons_of_rain", + "lots_of_rain", + "heavy_rain", + "heavy_rain_early", + "heavy_rain_early", + ], + "rainy": [ + "rain_late", + "showers_late", + "rain_early", + "showery", + "showers_early", + "numerous_showers", + "rain", + "light_rain_late", + "sprinkles_late", + "light_rain_early", + "sprinkles_early", + "light_rain", + "sprinkles", + "drizzle", + "night_showers", + "night_sprinkles", + "night_rain_showers", + "night_passing_showers", + "night_light_showers", + "night_a_few_showers", + "night_scattered_showers", + "rain_early", + "scattered_showers", + "a_few_showers", + "light_showers", + "passing_showers", + "rain_showers", + "showers", + ], + "snowy": [ + "heavy_snow_late", + "heavy_snow_early", + "heavy_snow", + "snow_late", + "snow_early", + "moderate_snow", + "snow", + "light_snow_late", + "snow_showers_late", + "flurries_late", + "light_snow_early", + "flurries_early", + "light_snow", + "snow_flurries", + "sleet", + "an_icy_mix_changing_to_snow", + "an_icy_mix_changing_to_rain", + "rain_changing_to_snow", + "icy_mix_early", + "light_icy_mix_late", + "icy_mix_late", + "scattered_flurries", + ], + "snowy-rainy": [ + "freezing_rain", + "light_freezing_rain", + "snow_showers_early", + "snow_showers", + "light_snow_showers", + "snow_rain_mix", + "light_icy_mix_early", + "rain_changing_to_an_icy_mix", + "snow_changing_to_an_icy_mix", + "snow_changing_to_rain", + "heavy_mixture_of_precip", + "light_mixture_of_precip", + "icy_mix", + "mixture_of_precip", + ], + "sunny": ["sunny", "clear", "mostly_sunny", "mostly_clear"], + "windy": ["strong_thunderstorms", "severe_thunderstorms"], + "windy-variant": [], + "exceptional": [ + "blizzard", + "snowstorm", + "duststorm", + "sandstorm", + "hurricane", + "tropical_storm", + "flood", + "flash_floods", + "tornado", + ], +} diff --git a/custom_components/here_weather/manifest.json b/custom_components/here_weather/manifest.json new file mode 100644 index 00000000..98bf0ac5 --- /dev/null +++ b/custom_components/here_weather/manifest.json @@ -0,0 +1,16 @@ +{ + "domain": "here_weather", + "name": "HERE Destination Weather", + "documentation": "https://github.com/eifinger/hass-here-weather", + "issue_tracker": "https://github.com/eifinger/hass-here-weather/issues", + "dependencies": [], + "version": "1.0.2", + "config_flow": true, + "codeowners": [ + "@eifinger" + ], + "requirements": [ + "aiohere==1.2.0" + ], + "iot_class": "cloud_polling" +} \ No newline at end of file diff --git a/custom_components/here_weather/sensor.py b/custom_components/here_weather/sensor.py new file mode 100644 index 00000000..62bbf241 --- /dev/null +++ b/custom_components/here_weather/sensor.py @@ -0,0 +1,116 @@ +"""Sensor platform for the HERE Destination Weather service.""" +from __future__ import annotations + +from homeassistant.components.sensor import SensorEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.typing import StateType +from homeassistant.helpers.update_coordinator import ( + CoordinatorEntity, + DataUpdateCoordinator, +) + +from .const import DOMAIN, SENSOR_TYPES +from .utils import convert_unit_of_measurement_if_needed, get_attribute_from_here_data + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities +): + """Add here_weather entities from a ConfigEntry.""" + here_weather_coordinators = hass.data[DOMAIN][entry.entry_id] + + sensors_to_add = [] + for sensor_type, weather_attributes in SENSOR_TYPES.items(): + for weather_attribute in weather_attributes: + sensors_to_add.append( + HEREDestinationWeatherSensor( + entry, + here_weather_coordinators[sensor_type], + sensor_type, + weather_attribute, + ) + ) + async_add_entities(sensors_to_add) + + +class HEREDestinationWeatherSensor(CoordinatorEntity, SensorEntity): + """Implementation of an HERE Destination Weather sensor.""" + + def __init__( + self, + entry: ConfigEntry, + coordinator: DataUpdateCoordinator, + sensor_type: str, + weather_attribute: str, + sensor_number: int = 0, # Additional supported offsets will be added in a separate PR + ) -> None: + """Initialize the sensor.""" + super().__init__(coordinator) + self._base_name = entry.data[CONF_NAME] + self._name_suffix = SENSOR_TYPES[sensor_type][weather_attribute]["name"] + self._latitude = entry.data[CONF_LATITUDE] + self._longitude = entry.data[CONF_LONGITUDE] + self._sensor_type = sensor_type + self._sensor_number = sensor_number + self._weather_attribute = weather_attribute + self._unit_of_measurement = convert_unit_of_measurement_if_needed( + self.coordinator.hass.config.units.name, + SENSOR_TYPES[sensor_type][weather_attribute]["unit_of_measurement"], + ) + self._device_class = SENSOR_TYPES[sensor_type][weather_attribute][ + "device_class" + ] + self._unique_id = "".join( + f"{self._latitude}_{self._longitude}_{self._sensor_type}_{self._name_suffix}_{self._sensor_number}".lower().split() + ) + self._unique_device_id = "".join( + f"{self._latitude}_{self._longitude}_{self._sensor_type}".lower().split() + ) + + @property + def entity_registry_enabled_default(self): + """Return if the entity should be enabled when first added to the entity registry.""" + return False + + @property + def name(self) -> str: + """Return the name of the sensor.""" + return f"{self._base_name} {self._sensor_type} {self._name_suffix} {self._sensor_number}" + + @property + def unique_id(self) -> str: + """Set unique_id for sensor.""" + return self._unique_id + + @property + def state(self) -> StateType: + """Return the state of the device.""" + return get_attribute_from_here_data( + self.coordinator.data, + self._weather_attribute, + self._sensor_number, + ) + + @property + def unit_of_measurement(self) -> str | None: + """Return the unit of measurement of this entity, if any.""" + return self._unit_of_measurement + + @property + def device_info(self) -> DeviceInfo: + """Return a device description for device registry.""" + + return { + "identifiers": {(DOMAIN, self._unique_device_id)}, + "name": f"{self._base_name} {self._sensor_type}", + "manufacturer": "here.com", + "entry_type": "service", + } + + @property + def device_class(self) -> str | None: + """Return the class of this device.""" + return self._device_class diff --git a/custom_components/here_weather/translations/de.json b/custom_components/here_weather/translations/de.json new file mode 100644 index 00000000..9648d1f9 --- /dev/null +++ b/custom_components/here_weather/translations/de.json @@ -0,0 +1,19 @@ +{ + "config": { + "step": { + "user": { + "description": "Möchtest Du mit der Einrichtung beginnen?", + "data": { + "api_key": "API-Schlücssel", + "latitude": "Breitengrad", + "longitude": "Längengrad", + "name": "Name" + } + } + }, + "error": { + "invalid_request": "HERE hat eine invalide Anfrage entdeckt. Das deutet daraufhin, dass die Koordination falsch sind.", + "unauthorized": "Ungültiger API Key." + } + } +} \ No newline at end of file diff --git a/custom_components/here_weather/translations/en.json b/custom_components/here_weather/translations/en.json new file mode 100644 index 00000000..926ef773 --- /dev/null +++ b/custom_components/here_weather/translations/en.json @@ -0,0 +1,19 @@ +{ + "config": { + "step": { + "user": { + "description": "Do you want to start set up?", + "data": { + "api_key": "API Key", + "latitude": "Latitude", + "longitude": "Longitude", + "name": "Name" + } + } + }, + "error": { + "invalid_request": "HERE reported an invalid request. This indicates the supplied location is not valid.", + "unauthorized": "Invalid credentials. This error is returned if the specified token was invalid or no contract could be found for this token." + } + } +} \ No newline at end of file diff --git a/custom_components/here_weather/utils.py b/custom_components/here_weather/utils.py new file mode 100644 index 00000000..13dc85eb --- /dev/null +++ b/custom_components/here_weather/utils.py @@ -0,0 +1,62 @@ +"""Utility functions for here_weather.""" +from __future__ import annotations + +from homeassistant.const import ( + CONF_UNIT_SYSTEM_METRIC, + LENGTH_CENTIMETERS, + LENGTH_INCHES, + LENGTH_KILOMETERS, + LENGTH_MILES, + PRESSURE_INHG, + PRESSURE_MBAR, + SPEED_KILOMETERS_PER_HOUR, + SPEED_MILES_PER_HOUR, + TEMP_CELSIUS, + TEMP_FAHRENHEIT, +) + + +def convert_temperature_unit_of_measurement_if_needed( + unit_system: str, unit_of_measurement: str +) -> str: + """Convert the temperature unit of measurement to imperial if configured.""" + if unit_system != CONF_UNIT_SYSTEM_METRIC: + unit_of_measurement = TEMP_FAHRENHEIT + return unit_of_measurement + + +def convert_unit_of_measurement_if_needed( + unit_system: str, unit_of_measurement: str | None +) -> str | None: + """Convert the unit of measurement to imperial if configured.""" + if unit_system != CONF_UNIT_SYSTEM_METRIC: + if unit_of_measurement == TEMP_CELSIUS: + unit_of_measurement = TEMP_FAHRENHEIT + elif unit_of_measurement == LENGTH_CENTIMETERS: + unit_of_measurement = LENGTH_INCHES + elif unit_of_measurement == SPEED_KILOMETERS_PER_HOUR: + unit_of_measurement = SPEED_MILES_PER_HOUR + elif unit_of_measurement == PRESSURE_MBAR: + unit_of_measurement = PRESSURE_INHG + elif unit_of_measurement == LENGTH_KILOMETERS: + unit_of_measurement = LENGTH_MILES + return unit_of_measurement + + +def get_attribute_from_here_data( + here_data: list, attribute_name: str, sensor_number: int = 0 +) -> str | None: + """Extract and convert data from HERE response or None if not found.""" + try: + state = here_data[sensor_number][attribute_name] + except KeyError: + return None + state = convert_asterisk_to_none(state) + return state + + +def convert_asterisk_to_none(state: str) -> str | None: + """Convert HERE API representation of None.""" + if state == "*": + return None + return state diff --git a/custom_components/here_weather/weather.py b/custom_components/here_weather/weather.py new file mode 100644 index 00000000..524d7c3e --- /dev/null +++ b/custom_components/here_weather/weather.py @@ -0,0 +1,296 @@ +"""Weather platform for the HERE Destination Weather service.""" +from __future__ import annotations + +from homeassistant.components.weather import ( + ATTR_FORECAST_CONDITION, + ATTR_FORECAST_PRECIPITATION, + ATTR_FORECAST_PRECIPITATION_PROBABILITY, + ATTR_FORECAST_PRESSURE, + ATTR_FORECAST_TEMP, + ATTR_FORECAST_TEMP_LOW, + ATTR_FORECAST_TIME, + ATTR_FORECAST_WIND_BEARING, + ATTR_FORECAST_WIND_SPEED, + Forecast, + WeatherEntity, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME, TEMP_CELSIUS +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.update_coordinator import ( + CoordinatorEntity, + DataUpdateCoordinator, +) + +from .const import ( + CONDITION_CLASSES, + DEFAULT_MODE, + DOMAIN, + MODE_ASTRONOMY, + MODE_DAILY_SIMPLE, + SENSOR_TYPES, +) +from .utils import ( + convert_temperature_unit_of_measurement_if_needed, + get_attribute_from_here_data, +) + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities +): + """Add here_weather entities from a ConfigEntry.""" + here_weather_coordinators = hass.data[DOMAIN][entry.entry_id] + + entities_to_add = [] + for sensor_type in SENSOR_TYPES: + if sensor_type != MODE_ASTRONOMY: + entities_to_add.append( + HEREDestinationWeather( + entry, + here_weather_coordinators[sensor_type], + sensor_type, + ) + ) + async_add_entities(entities_to_add) + + +class HEREDestinationWeather(CoordinatorEntity, WeatherEntity): + """Implementation of an HERE Destination Weather WeatherEntity.""" + + def __init__( + self, entry: ConfigEntry, coordinator: DataUpdateCoordinator, mode: str + ) -> None: + """Initialize the sensor.""" + super().__init__(coordinator) + self._name = entry.data[CONF_NAME] + self._mode = mode + self._unique_id = "".join( + f"{entry.data[CONF_LATITUDE]}_{entry.data[CONF_LONGITUDE]}_{self._mode}".lower().split() + ) + + @property + def name(self): + """Return the name of the sensor.""" + return f"{self._name} {self._mode}" + + @property + def unique_id(self): + """Set unique_id for sensor.""" + return self._unique_id + + @property + def condition(self) -> str | None: + """Return the current condition.""" + return get_condition_from_here_data(self.coordinator.data) + + @property + def temperature(self) -> float | None: + """Return the temperature.""" + return get_temperature_from_here_data(self.coordinator.data, self._mode) + + @property + def temperature_unit(self) -> str: + """Return the unit of measurement.""" + return convert_temperature_unit_of_measurement_if_needed( + self.coordinator.hass.config.units.name, TEMP_CELSIUS + ) + + @property + def pressure(self) -> float | None: + """Return the pressure.""" + return get_pressure_from_here_data(self.coordinator.data, self._mode) + + @property + def humidity(self) -> float | None: + """Return the humidity.""" + if ( + humidity := get_attribute_from_here_data(self.coordinator.data, "humidity") + ) is not None: + return float(humidity) + return None + + @property + def wind_speed(self) -> float | None: + """Return the wind speed.""" + return get_wind_speed_from_here_data(self.coordinator.data) + + @property + def wind_bearing(self) -> float | str | None: + """Return the wind bearing.""" + return get_wind_bearing_from_here_data(self.coordinator.data) + + @property + def attribution(self) -> str | None: + """Return the attribution.""" + return None + + @property + def visibility(self) -> float | None: + """Return the visibility.""" + if "visibility" in SENSOR_TYPES[self._mode]: + if ( + visibility := get_attribute_from_here_data( + self.coordinator.data, "visibility" + ) + ) is not None: + return float(visibility) + return None + + @property + def forecast(self) -> list[Forecast] | None: + """Return the forecast array.""" + data: list[Forecast] = [] + for offset in range(len(self.coordinator.data)): + data.append( + { + ATTR_FORECAST_CONDITION: get_condition_from_here_data( + self.coordinator.data, offset + ), + ATTR_FORECAST_TIME: get_time_from_here_data( + self.coordinator.data, offset + ), + ATTR_FORECAST_PRECIPITATION_PROBABILITY: get_precipitation_probability( + self.coordinator.data, self._mode, offset + ), + ATTR_FORECAST_PRECIPITATION: calc_precipitation( + self.coordinator.data, offset + ), + ATTR_FORECAST_PRESSURE: get_pressure_from_here_data( + self.coordinator.data, self._mode, offset + ), + ATTR_FORECAST_TEMP: get_high_or_default_temperature_from_here_data( + self.coordinator.data, self._mode, offset + ), + ATTR_FORECAST_TEMP_LOW: get_low_or_default_temperature_from_here_data( + self.coordinator.data, self._mode, offset + ), + ATTR_FORECAST_WIND_BEARING: get_wind_bearing_from_here_data( + self.coordinator.data, offset + ), + ATTR_FORECAST_WIND_SPEED: get_wind_speed_from_here_data( + self.coordinator.data, offset + ), + } + ) + return data + + @property + def entity_registry_enabled_default(self): + """Return if the entity should be enabled when first added to the entity registry.""" + return self._mode == DEFAULT_MODE + + @property + def device_info(self) -> DeviceInfo: + """Return a device description for device registry.""" + return { + "identifiers": {(DOMAIN, self._unique_id)}, + "name": self.name, + "manufacturer": "here.com", + "entry_type": "service", + } + + +def get_wind_speed_from_here_data(here_data: list, offset: int = 0) -> float: + """Return the wind speed from here_data.""" + wind_speed = get_attribute_from_here_data(here_data, "windSpeed", offset) + assert wind_speed is not None + return float(wind_speed) + + +def get_wind_bearing_from_here_data(here_data: list, offset: int = 0) -> int: + """Return the wind bearing from here_data.""" + wind_bearing = get_attribute_from_here_data(here_data, "windDirection", offset) + assert wind_bearing is not None + return int(wind_bearing) + + +def get_time_from_here_data(here_data: list, offset: int = 0) -> str: + """Return the time from here_data.""" + time = get_attribute_from_here_data(here_data, "utcTime", offset) + assert time is not None + return time + + +def get_pressure_from_here_data( + here_data: list, mode: str, offset: int = 0 +) -> float | None: + """Return the pressure from here_data.""" + if "barometerPressure" in SENSOR_TYPES[mode]: + if ( + pressure := get_attribute_from_here_data( + here_data, "barometerPressure", offset + ) + ) is not None: + return float(pressure) + return None + + +def get_precipitation_probability( + here_data: list, mode: str, offset: int = 0 +) -> int | None: + """Return the precipitation probability from here_data.""" + if "precipitationProbability" in SENSOR_TYPES[mode]: + if ( + precipitation_probability := get_attribute_from_here_data( + here_data, "precipitationProbability", offset + ) + ) is not None: + return int(precipitation_probability) + return None + + +def get_condition_from_here_data(here_data: list, offset: int = 0) -> str | None: + """Return the condition from here_data.""" + return next( + ( + k + for k, v in CONDITION_CLASSES.items() + if get_attribute_from_here_data(here_data, "iconName", offset) in v + ), + None, + ) + + +def get_high_or_default_temperature_from_here_data( + here_data: list, mode: str, offset: int = 0 +) -> float | None: + """Return the temperature from here_data.""" + temperature = get_attribute_from_here_data(here_data, "highTemperature", offset) + if temperature is not None: + return float(temperature) + + return get_temperature_from_here_data(here_data, mode, offset) + + +def get_low_or_default_temperature_from_here_data( + here_data: list, mode: str, offset: int = 0 +) -> float | None: + """Return the temperature from here_data.""" + temperature = get_attribute_from_here_data(here_data, "lowTemperature", offset) + if temperature is not None: + return float(temperature) + return get_temperature_from_here_data(here_data, mode, offset) + + +def get_temperature_from_here_data( + here_data: list, mode: str, offset: int = 0 +) -> float | None: + """Return the temperature from here_data.""" + if mode == MODE_DAILY_SIMPLE: + temperature = get_attribute_from_here_data(here_data, "highTemperature", offset) + else: + temperature = get_attribute_from_here_data(here_data, "temperature", offset) + if temperature is not None: + return float(temperature) + return None + + +def calc_precipitation(here_data: list, offset: int = 0) -> float | None: + """Calculate Precipitation.""" + rain_fall = get_attribute_from_here_data(here_data, "rainFall", offset) + snow_fall = get_attribute_from_here_data(here_data, "snowFall", offset) + if rain_fall is not None and snow_fall is not None: + return float(rain_fall) + float(snow_fall) + return None diff --git a/custom_components/ssh/manifest.json b/custom_components/ssh/manifest.json index 14cb2589..3750ed71 100644 --- a/custom_components/ssh/manifest.json +++ b/custom_components/ssh/manifest.json @@ -4,5 +4,6 @@ "documentation": "https://github.com/custom-components/sensor.ssh", "requirements": ["pexpect==4.6.0"], "dependencies": [], - "codeowners": ["@jchasey"] + "codeowners": ["@jchasey"], + "version":"0.1.5" } diff --git a/www/community/lovelace-auto-entities/auto-entities.js b/www/community/lovelace-auto-entities/auto-entities.js index fe39af4d..e34de1c5 100644 --- a/www/community/lovelace-auto-entities/auto-entities.js +++ b/www/community/lovelace-auto-entities/auto-entities.js @@ -1,4 +1,4 @@ -function t(t,e,i,s){var n,o=arguments.length,r=o<3?e:null===s?s=Object.getOwnPropertyDescriptor(e,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)r=Reflect.decorate(t,e,i,s);else for(var a=t.length-1;a>=0;a--)(n=t[a])&&(r=(o<3?n(r):o>3?n(e,i,r):n(e,i))||r);return o>3&&r&&Object.defineProperty(e,i,r),r}const e="undefined"!=typeof window&&null!=window.customElements&&void 0!==window.customElements.polyfillWrapFlushCallback,i=(t,e,i=null)=>{for(;e!==i;){const i=e.nextSibling;t.removeChild(e),e=i}},s=`{{lit-${String(Math.random()).slice(2)}}}`,n=`\x3c!--${s}--\x3e`,o=new RegExp(`${s}|${n}`);class r{constructor(t,e){this.parts=[],this.element=e;const i=[],n=[],r=document.createTreeWalker(e.content,133,null,!1);let c=0,h=-1,u=0;const{strings:p,values:{length:f}}=t;for(;u0;){const e=p[u],i=d.exec(e)[2],s=i.toLowerCase()+"$lit$",n=t.getAttribute(s);t.removeAttribute(s);const r=n.split(o);this.parts.push({type:"attribute",index:h,name:i,strings:r}),u+=r.length-1}}"TEMPLATE"===t.tagName&&(n.push(t),r.currentNode=t.content)}else if(3===t.nodeType){const e=t.data;if(e.indexOf(s)>=0){const s=t.parentNode,n=e.split(o),r=n.length-1;for(let e=0;e{const i=t.length-e.length;return i>=0&&t.slice(i)===e},c=t=>-1!==t.index,l=()=>document.createComment(""),d=/([ \x09\x0a\x0c\x0d])([^\0-\x1F\x7F-\x9F "'>=/]+)([ \x09\x0a\x0c\x0d]*=[ \x09\x0a\x0c\x0d]*(?:[^ \x09\x0a\x0c\x0d"'`<>=]*|"[^"]*|'[^']*))$/;function h(t,e){const{element:{content:i},parts:s}=t,n=document.createTreeWalker(i,133,null,!1);let o=p(s),r=s[o],a=-1,c=0;const l=[];let d=null;for(;n.nextNode();){a++;const t=n.currentNode;for(t.previousSibling===d&&(d=null),e.has(t)&&(l.push(t),null===d&&(d=t)),null!==d&&c++;void 0!==r&&r.index===a;)r.index=null!==d?-1:r.index-c,o=p(s,o),r=s[o]}l.forEach((t=>t.parentNode.removeChild(t)))}const u=t=>{let e=11===t.nodeType?0:1;const i=document.createTreeWalker(t,133,null,!1);for(;i.nextNode();)e++;return e},p=(t,e=-1)=>{for(let i=e+1;i"function"==typeof t&&f.has(t),_={},v={};class m{constructor(t,e,i){this.__parts=[],this.template=t,this.processor=e,this.options=i}update(t){let e=0;for(const i of this.__parts)void 0!==i&&i.setValue(t[e]),e++;for(const t of this.__parts)void 0!==t&&t.commit()}_clone(){const t=e?this.template.element.content.cloneNode(!0):document.importNode(this.template.element.content,!0),i=[],s=this.template.parts,n=document.createTreeWalker(t,133,null,!1);let o,r=0,a=0,l=n.nextNode();for(;rt}),b=` ${s} `;class w{constructor(t,e,i,s){this.strings=t,this.values=e,this.type=i,this.processor=s}getHTML(){const t=this.strings.length-1;let e="",i=!1;for(let o=0;o-1||i)&&-1===t.indexOf("--\x3e",r+1);const a=d.exec(t);e+=null===a?t+(i?b:n):t.substr(0,a.index)+a[1]+a[2]+"$lit$"+a[3]+s}return e+=this.strings[t],e}getTemplateElement(){const t=document.createElement("template");let e=this.getHTML();return void 0!==y&&(e=y.createHTML(e)),t.innerHTML=e,t}}const S=t=>null===t||!("object"==typeof t||"function"==typeof t),C=t=>Array.isArray(t)||!(!t||!t[Symbol.iterator]);class O{constructor(t,e,i){this.dirty=!0,this.element=t,this.name=e,this.strings=i,this.parts=[];for(let t=0;t{try{const t={get capture(){return N=!0,!1}};window.addEventListener("test",t,t),window.removeEventListener("test",t,t)}catch(t){}})();class T{constructor(t,e,i){this.value=void 0,this.__pendingValue=void 0,this.element=t,this.eventName=e,this.eventContext=i,this.__boundHandleEvent=t=>this.handleEvent(t)}setValue(t){this.__pendingValue=t}commit(){for(;g(this.__pendingValue);){const t=this.__pendingValue;this.__pendingValue=_,t(this)}if(this.__pendingValue===_)return;const t=this.__pendingValue,e=this.value,i=null==t||null!=e&&(t.capture!==e.capture||t.once!==e.once||t.passive!==e.passive),s=null!=t&&(null==e||i);i&&this.element.removeEventListener(this.eventName,this.__boundHandleEvent,this.__options),s&&(this.__options=k(t),this.element.addEventListener(this.eventName,this.__boundHandleEvent,this.__options)),this.value=t,this.__pendingValue=_}handleEvent(t){"function"==typeof this.value?this.value.call(this.eventContext||this.element,t):this.value.handleEvent(t)}}const k=t=>t&&(N?{capture:t.capture,passive:t.passive,once:t.once}:t.capture);function A(t){let e=M.get(t.type);void 0===e&&(e={stringsArray:new WeakMap,keyString:new Map},M.set(t.type,e));let i=e.stringsArray.get(t.strings);if(void 0!==i)return i;const n=t.strings.join(s);return i=e.keyString.get(n),void 0===i&&(i=new r(t,t.getTemplateElement()),e.keyString.set(n,i)),e.stringsArray.set(t.strings,i),i}const M=new Map,U=new WeakMap;const F=new class{handleAttributeExpressions(t,e,i,s){const n=e[0];if("."===n){return new P(t,e.slice(1),i).parts}if("@"===n)return[new T(t,e.slice(1),s.eventContext)];if("?"===n)return[new j(t,e.slice(1),i)];return new O(t,e,i).parts}handleTextExpression(t){return new x(t)}};"undefined"!=typeof window&&(window.litHtmlVersions||(window.litHtmlVersions=[])).push("1.3.0");const V=(t,...e)=>new w(t,e,"html",F),I=(t,e)=>`${t}--${e}`;let R=!0;void 0===window.ShadyCSS?R=!1:void 0===window.ShadyCSS.prepareTemplateDom&&(console.warn("Incompatible ShadyCSS version detected. Please update to at least @webcomponents/webcomponentsjs@2.0.2 and @webcomponents/shadycss@1.3.1."),R=!1);const D=t=>e=>{const i=I(e.type,t);let n=M.get(i);void 0===n&&(n={stringsArray:new WeakMap,keyString:new Map},M.set(i,n));let o=n.stringsArray.get(e.strings);if(void 0!==o)return o;const a=e.strings.join(s);if(o=n.keyString.get(a),void 0===o){const i=e.getTemplateElement();R&&window.ShadyCSS.prepareTemplateDom(i,t),o=new r(e,i),n.keyString.set(a,o)}return n.stringsArray.set(e.strings,o),o},q=["html","svg"],G=new Set,W=(t,e,i)=>{G.add(t);const s=i?i.element:document.createElement("template"),n=e.querySelectorAll("style"),{length:o}=n;if(0===o)return void window.ShadyCSS.prepareTemplateStyles(s,t);const r=document.createElement("style");for(let t=0;t{q.forEach((e=>{const i=M.get(I(e,t));void 0!==i&&i.keyString.forEach((t=>{const{element:{content:e}}=t,i=new Set;Array.from(e.querySelectorAll("style")).forEach((t=>{i.add(t)})),h(t,i)}))}))})(t);const a=s.content;i?function(t,e,i=null){const{element:{content:s},parts:n}=t;if(null==i)return void s.appendChild(e);const o=document.createTreeWalker(s,133,null,!1);let r=p(n),a=0,c=-1;for(;o.nextNode();)for(c++,o.currentNode===i&&(a=u(e),i.parentNode.insertBefore(e,i));-1!==r&&n[r].index===c;){if(a>0){for(;-1!==r;)n[r].index+=a,r=p(n,r);return}r=p(n,r)}}(i,r,a.firstChild):a.insertBefore(r,a.firstChild),window.ShadyCSS.prepareTemplateStyles(s,t);const c=a.querySelector("style");if(window.ShadyCSS.nativeShadow&&null!==c)e.insertBefore(c.cloneNode(!0),e.firstChild);else if(i){a.insertBefore(r,a.firstChild);const t=new Set;t.add(r),h(i,t)}};window.JSCompiler_renameProperty=(t,e)=>t;const z={toAttribute(t,e){switch(e){case Boolean:return t?"":null;case Object:case Array:return null==t?t:JSON.stringify(t)}return t},fromAttribute(t,e){switch(e){case Boolean:return null!==t;case Number:return null===t?null:Number(t);case Object:case Array:return JSON.parse(t)}return t}},L=(t,e)=>e!==t&&(e==e||t==t),B={attribute:!0,type:String,converter:z,reflect:!1,hasChanged:L};class H extends HTMLElement{constructor(){super(),this.initialize()}static get observedAttributes(){this.finalize();const t=[];return this._classProperties.forEach(((e,i)=>{const s=this._attributeNameForProperty(i,e);void 0!==s&&(this._attributeToPropertyMap.set(s,i),t.push(s))})),t}static _ensureClassProperties(){if(!this.hasOwnProperty(JSCompiler_renameProperty("_classProperties",this))){this._classProperties=new Map;const t=Object.getPrototypeOf(this)._classProperties;void 0!==t&&t.forEach(((t,e)=>this._classProperties.set(e,t)))}}static createProperty(t,e=B){if(this._ensureClassProperties(),this._classProperties.set(t,e),e.noAccessor||this.prototype.hasOwnProperty(t))return;const i="symbol"==typeof t?Symbol():`__${t}`,s=this.getPropertyDescriptor(t,i,e);void 0!==s&&Object.defineProperty(this.prototype,t,s)}static getPropertyDescriptor(t,e,i){return{get(){return this[e]},set(s){const n=this[t];this[e]=s,this.requestUpdateInternal(t,n,i)},configurable:!0,enumerable:!0}}static getPropertyOptions(t){return this._classProperties&&this._classProperties.get(t)||B}static finalize(){const t=Object.getPrototypeOf(this);if(t.hasOwnProperty("finalized")||t.finalize(),this.finalized=!0,this._ensureClassProperties(),this._attributeToPropertyMap=new Map,this.hasOwnProperty(JSCompiler_renameProperty("properties",this))){const t=this.properties,e=[...Object.getOwnPropertyNames(t),..."function"==typeof Object.getOwnPropertySymbols?Object.getOwnPropertySymbols(t):[]];for(const i of e)this.createProperty(i,t[i])}}static _attributeNameForProperty(t,e){const i=e.attribute;return!1===i?void 0:"string"==typeof i?i:"string"==typeof t?t.toLowerCase():void 0}static _valueHasChanged(t,e,i=L){return i(t,e)}static _propertyValueFromAttribute(t,e){const i=e.type,s=e.converter||z,n="function"==typeof s?s:s.fromAttribute;return n?n(t,i):t}static _propertyValueToAttribute(t,e){if(void 0===e.reflect)return;const i=e.type,s=e.converter;return(s&&s.toAttribute||z.toAttribute)(t,i)}initialize(){this._updateState=0,this._updatePromise=new Promise((t=>this._enableUpdatingResolver=t)),this._changedProperties=new Map,this._saveInstanceProperties(),this.requestUpdateInternal()}_saveInstanceProperties(){this.constructor._classProperties.forEach(((t,e)=>{if(this.hasOwnProperty(e)){const t=this[e];delete this[e],this._instanceProperties||(this._instanceProperties=new Map),this._instanceProperties.set(e,t)}}))}_applyInstanceProperties(){this._instanceProperties.forEach(((t,e)=>this[e]=t)),this._instanceProperties=void 0}connectedCallback(){this.enableUpdating()}enableUpdating(){void 0!==this._enableUpdatingResolver&&(this._enableUpdatingResolver(),this._enableUpdatingResolver=void 0)}disconnectedCallback(){}attributeChangedCallback(t,e,i){e!==i&&this._attributeToProperty(t,i)}_propertyToAttribute(t,e,i=B){const s=this.constructor,n=s._attributeNameForProperty(t,i);if(void 0!==n){const t=s._propertyValueToAttribute(e,i);if(void 0===t)return;this._updateState=8|this._updateState,null==t?this.removeAttribute(n):this.setAttribute(n,t),this._updateState=-9&this._updateState}}_attributeToProperty(t,e){if(8&this._updateState)return;const i=this.constructor,s=i._attributeToPropertyMap.get(t);if(void 0!==s){const t=i.getPropertyOptions(s);this._updateState=16|this._updateState,this[s]=i._propertyValueFromAttribute(e,t),this._updateState=-17&this._updateState}}requestUpdateInternal(t,e,i){let s=!0;if(void 0!==t){const n=this.constructor;i=i||n.getPropertyOptions(t),n._valueHasChanged(this[t],e,i.hasChanged)?(this._changedProperties.has(t)||this._changedProperties.set(t,e),!0!==i.reflect||16&this._updateState||(void 0===this._reflectingProperties&&(this._reflectingProperties=new Map),this._reflectingProperties.set(t,i))):s=!1}!this._hasRequestedUpdate&&s&&(this._updatePromise=this._enqueueUpdate())}requestUpdate(t,e){return this.requestUpdateInternal(t,e),this.updateComplete}async _enqueueUpdate(){this._updateState=4|this._updateState;try{await this._updatePromise}catch(t){}const t=this.performUpdate();return null!=t&&await t,!this._hasRequestedUpdate}get _hasRequestedUpdate(){return 4&this._updateState}get hasUpdated(){return 1&this._updateState}performUpdate(){if(!this._hasRequestedUpdate)return;this._instanceProperties&&this._applyInstanceProperties();let t=!1;const e=this._changedProperties;try{t=this.shouldUpdate(e),t?this.update(e):this._markUpdated()}catch(e){throw t=!1,this._markUpdated(),e}t&&(1&this._updateState||(this._updateState=1|this._updateState,this.firstUpdated(e)),this.updated(e))}_markUpdated(){this._changedProperties=new Map,this._updateState=-5&this._updateState}get updateComplete(){return this._getUpdateComplete()}_getUpdateComplete(){return this._updatePromise}shouldUpdate(t){return!0}update(t){void 0!==this._reflectingProperties&&this._reflectingProperties.size>0&&(this._reflectingProperties.forEach(((t,e)=>this._propertyToAttribute(e,this[e],t))),this._reflectingProperties=void 0),this._markUpdated()}updated(t){}firstUpdated(t){}}H.finalized=!0;const J=(t,e)=>"method"===e.kind&&e.descriptor&&!("value"in e.descriptor)?Object.assign(Object.assign({},e),{finisher(i){i.createProperty(e.key,t)}}):{kind:"field",key:Symbol(),placement:"own",descriptor:{},initializer(){"function"==typeof e.initializer&&(this[e.key]=e.initializer.call(this))},finisher(i){i.createProperty(e.key,t)}};function K(t){return(e,i)=>void 0!==i?((t,e,i)=>{e.constructor.createProperty(i,t)})(t,e,i):J(t,e)}function Y(t){return K({attribute:!1,hasChanged:null==t?void 0:t.hasChanged})}const Q=(t,e,i)=>{Object.defineProperty(e,i,t)},X=(t,e)=>({kind:"method",placement:"prototype",key:e.key,descriptor:t}),Z=window.ShadowRoot&&(void 0===window.ShadyCSS||window.ShadyCSS.nativeShadow)&&"adoptedStyleSheets"in Document.prototype&&"replace"in CSSStyleSheet.prototype,tt=Symbol();class et{constructor(t,e){if(e!==tt)throw new Error("CSSResult is not constructable. Use `unsafeCSS` or `css` instead.");this.cssText=t}get styleSheet(){return void 0===this._styleSheet&&(Z?(this._styleSheet=new CSSStyleSheet,this._styleSheet.replaceSync(this.cssText)):this._styleSheet=null),this._styleSheet}toString(){return this.cssText}}const it=(t,...e)=>{const i=e.reduce(((e,i,s)=>e+(t=>{if(t instanceof et)return t.cssText;if("number"==typeof t)return t;throw new Error(`Value passed to 'css' function must be a 'css' function result: ${t}. Use 'unsafeCSS' to pass non-literal values, but\n take care to ensure page security.`)})(i)+t[s+1]),t[0]);return new et(i,tt)};(window.litElementVersions||(window.litElementVersions=[])).push("2.4.0");const st={};class nt extends H{static getStyles(){return this.styles}static _getUniqueStyles(){if(this.hasOwnProperty(JSCompiler_renameProperty("_styles",this)))return;const t=this.getStyles();if(Array.isArray(t)){const e=(t,i)=>t.reduceRight(((t,i)=>Array.isArray(i)?e(i,t):(t.add(i),t)),i),i=e(t,new Set),s=[];i.forEach((t=>s.unshift(t))),this._styles=s}else this._styles=void 0===t?[]:[t];this._styles=this._styles.map((t=>{if(t instanceof CSSStyleSheet&&!Z){const e=Array.prototype.slice.call(t.cssRules).reduce(((t,e)=>t+e.cssText),"");return new et(String(e),tt)}return t}))}initialize(){super.initialize(),this.constructor._getUniqueStyles(),this.renderRoot=this.createRenderRoot(),window.ShadowRoot&&this.renderRoot instanceof window.ShadowRoot&&this.adoptStyles()}createRenderRoot(){return this.attachShadow({mode:"open"})}adoptStyles(){const t=this.constructor._styles;0!==t.length&&(void 0===window.ShadyCSS||window.ShadyCSS.nativeShadow?Z?this.renderRoot.adoptedStyleSheets=t.map((t=>t instanceof CSSStyleSheet?t:t.styleSheet)):this._needsShimAdoptedStyleSheets=!0:window.ShadyCSS.ScopingShim.prepareAdoptedCssText(t.map((t=>t.cssText)),this.localName))}connectedCallback(){super.connectedCallback(),this.hasUpdated&&void 0!==window.ShadyCSS&&window.ShadyCSS.styleElement(this)}update(t){const e=this.render();super.update(t),e!==st&&this.constructor.render(e,this.renderRoot,{scopeName:this.localName,eventContext:this}),this._needsShimAdoptedStyleSheets&&(this._needsShimAdoptedStyleSheets=!1,this.constructor._styles.forEach((t=>{const e=document.createElement("style");e.textContent=t.cssText,this.renderRoot.appendChild(e)})))}render(){return st}}function ot(){return document.querySelector("hc-main")?document.querySelector("hc-main").hass:document.querySelector("home-assistant")?document.querySelector("home-assistant").hass:void 0}nt.finalized=!0,nt.render=(t,e,s)=>{if(!s||"object"!=typeof s||!s.scopeName)throw new Error("The `scopeName` option is required.");const n=s.scopeName,o=U.has(e),r=R&&11===e.nodeType&&!!e.host,a=r&&!G.has(n),c=a?document.createDocumentFragment():e;if(((t,e,s)=>{let n=U.get(e);void 0===n&&(i(e,e.firstChild),U.set(e,n=new x(Object.assign({templateFactory:A},s))),n.appendInto(e)),n.setValue(t),n.commit()})(t,c,Object.assign({templateFactory:D(n)},s)),a){const t=U.get(c);U.delete(c);const s=t.value instanceof m?t.value.template:void 0;W(n,c,s),i(e,e.firstChild),e.appendChild(c),U.set(e,t)}!o&&r&&window.ShadyCSS.styleElement(e.host)};const rt="lovelace-player-device-id";function at(){if(!localStorage[rt]){const t=()=>Math.floor(1e5*(1+Math.random())).toString(16).substring(1);window.fully&&"function"==typeof fully.getDeviceId?localStorage[rt]=fully.getDeviceId():localStorage[rt]=`${t()}${t()}-${t()}${t()}`}return localStorage[rt]}let ct=at();const lt=new URLSearchParams(window.location.search);var dt;function ht(t){return!!String(t).includes("{%")||(!!String(t).includes("{{")||void 0)}lt.get("deviceID")&&null!==(dt=lt.get("deviceID"))&&("clear"===dt?localStorage.removeItem(rt):localStorage[rt]=dt,ct=at()),window.cardMod_template_cache=window.cardMod_template_cache||{};const ut=window.cardMod_template_cache;async function pt(t,e,i){const s=ot().connection,n=JSON.stringify([e,i]);let o=ut[n];o?(o.callbacks.has(t)||ft(t),t(o.value),o.callbacks.add(t)):(ft(t),t(""),i=Object.assign({user:ot().user.name,browser:ct,hash:location.hash.substr(1)||""},i),ut[n]=o={template:e,variables:i,value:"",callbacks:new Set([t]),unsubscribe:s.subscribeMessage((t=>function(t,e){const i=ut[t];i&&(i.value=e.result,i.callbacks.forEach((t=>t(e.result))))}(n,t)),{type:"render_template",template:e,variables:i})})}async function ft(t){let e;for(const[i,s]of Object.entries(ut))if(s.callbacks.has(t)){s.callbacks.delete(t),0==s.callbacks.size&&(e=s.unsubscribe,delete ut[i]);break}e&&await(await e)()}var gt;function _t(t,e){if("string"==typeof e&&"string"==typeof t&&(t.startsWith("/")&&t.endsWith("/")||-1!==t.indexOf("*"))){return t.startsWith("/")||(t=`/^${t=t.replace(/\./g,".").replace(/\*/g,".*")}$/`),new RegExp(t.slice(1,-1)).test(e)}if("string"==typeof t){if(t.startsWith("<="))return parseFloat(e)<=parseFloat(t.substr(2));if(t.startsWith(">="))return parseFloat(e)>=parseFloat(t.substr(2));if(t.startsWith("<"))return parseFloat(e)"))return parseFloat(e)>parseFloat(t.substr(1));if(t.startsWith("!"))return parseFloat(e)!=parseFloat(t.substr(1));if(t.startsWith("="))return parseFloat(e)==parseFloat(t.substr(1))}return t===e}window.autoEntities_cache=null!==(gt=window.autoEntities_cache)&&void 0!==gt?gt:{};const vt=window.autoEntities_cache;async function mt(t){var e;return vt.areas=null!==(e=vt.areas)&&void 0!==e?e:await t.callWS({type:"config/area_registry/list"}),vt.areas}async function yt(t){var e;return vt.devices=null!==(e=vt.devices)&&void 0!==e?e:await t.callWS({type:"config/device_registry/list"}),vt.devices}async function bt(t){var e;return vt.entities=null!==(e=vt.entities)&&void 0!==e?e:await t.callWS({type:"config/entity_registry/list"}),vt.entities}const wt={options:async()=>!0,sort:async()=>!0,domain:async(t,e,i)=>_t(e,i.entity_id.split(".")[0]),entity_id:async(t,e,i)=>_t(e,i.entity_id),state:async(t,e,i)=>_t(e,i.state),name:async(t,e,i)=>{var s;return _t(e,null===(s=i.attributes)||void 0===s?void 0:s.friendly_name)},group:async(t,e,i)=>{var s,n,o;return null===(o=null===(n=null===(s=t.states[e])||void 0===s?void 0:s.attributes)||void 0===n?void 0:n.entity_id)||void 0===o?void 0:o.includes(i.entity_id)},attributes:async(t,e,i)=>{for(const[t,s]of Object.entries(e)){let e=t.split(" ")[0],n=i.attributes;for(const t of e.split(":"))n=n?n[t]:void 0;if(void 0===n||!_t(s,n))return!1}return!0},not:async(t,e,i)=>!await St(t,e,i.entity_id),or:async(t,e,i)=>{for(const s of e)if(await St(t,s,i.entity_id))return!0;return!1},device:async(t,e,i)=>{const s=(await bt(t)).find((t=>t.entity_id===i.entity_id));if(!s)return!1;const n=(await yt(t)).find((t=>t.id===s.device_id));return!!n&&(_t(e,n.name_by_user)||_t(e,n.name))},area:async(t,e,i)=>{const s=(await bt(t)).find((t=>t.entity_id===i.entity_id));if(!s)return!1;let n=(await mt(t)).find((t=>t.area_id===s.area_id));if(n)return _t(e,n.name);const o=(await yt(t)).find((t=>t.id===s.device_id));return!!o&&(n=(await mt(t)).find((t=>t.area_id===o.area_id)),!!n&&_t(e,n.name))},last_changed:async(t,e,i)=>_t(e,((new Date).getTime()-new Date(i.last_changed).getTime())/6e4),last_updated:async(t,e,i)=>_t(e,((new Date).getTime()-new Date(i.last_updated).getTime())/6e4),last_triggered:async(t,e,i)=>{if(null==i.attributes.last_triggered)return!1;return _t(e,((new Date).getTime()-new Date(i.attributes.last_triggered).getTime())/6e4)}};async function St(t,e,i){var s;if(!t.states[i])return!1;for(let[n,o]of Object.entries(e))if(n=n.trim().split(" ")[0].trim(),!await(null===(s=wt[n])||void 0===s?void 0:s.call(wt,t,o,t.states[i])))return!1;return!0}function Ct(t,e,i){var s,n,o,r;const[a,c]=i.reverse?[-1,1]:[1,-1];return i.ignore_case&&(t=null!==(n=null===(s=null==t?void 0:t.toLowerCase)||void 0===s?void 0:s.call(t))&&void 0!==n?n:t,e=null!==(r=null===(o=null==e?void 0:e.toLowerCase)||void 0===o?void 0:o.call(e))&&void 0!==r?r:e),i.numeric&&(isNaN(parseFloat(t))&&isNaN(parseFloat(e))||(t=isNaN(parseFloat(t))?void 0:parseFloat(t),e=isNaN(parseFloat(e))?void 0:parseFloat(e))),void 0===t&&void 0===e?0:void 0===t?a:void 0===e||te?a:0}const Ot={none:()=>0,domain:(t,e,i)=>{var s,n;return Ct(null===(s=null==t?void 0:t.entity_id)||void 0===s?void 0:s.split(".")[0],null===(n=null==e?void 0:e.entity_id)||void 0===n?void 0:n.split(".")[0],i)},entity_id:(t,e,i)=>Ct(null==t?void 0:t.entity_id,null==e?void 0:e.entity_id,i),friendly_name:(t,e,i)=>{var s,n,o,r;return Ct((null===(s=null==t?void 0:t.attributes)||void 0===s?void 0:s.friendly_name)||(null===(n=null==t?void 0:t.entity_id)||void 0===n?void 0:n.split(".")[1]),(null===(o=null==e?void 0:e.attributes)||void 0===o?void 0:o.friendly_name)||(null===(r=null==e?void 0:e.entity_id)||void 0===r?void 0:r.split(".")[1]),i)},name:(t,e,i)=>{var s,n,o,r;return Ct((null===(s=null==t?void 0:t.attributes)||void 0===s?void 0:s.friendly_name)||(null===(n=null==t?void 0:t.entity_id)||void 0===n?void 0:n.split(".")[1]),(null===(o=null==e?void 0:e.attributes)||void 0===o?void 0:o.friendly_name)||(null===(r=null==e?void 0:e.entity_id)||void 0===r?void 0:r.split(".")[1]),i)},state:(t,e,i)=>Ct(null==t?void 0:t.state,null==e?void 0:e.state,i),attribute:(t,e,i)=>{var s;const[n,o]=(null==i?void 0:i.reverse)?[-1,1]:[1,-1];let r=null==t?void 0:t.attributes,a=null==e?void 0:e.attributes;for(const t of null===(s=null==i?void 0:i.attribute)||void 0===s?void 0:s.split(":")){if(void 0===r&&void 0===a)return 0;if(void 0===r)return n;if(void 0===a)return o;[r,a]=[r[t],a[t]]}return Ct(r,a,i)},last_changed:(t,e,i)=>(i.numeric=!0,Ct(new Date(null==t?void 0:t.last_changed).getTime(),new Date(null==e?void 0:e.last_changed).getTime(),i)),last_updated:(t,e,i)=>(i.numeric=!0,Ct(new Date(null==t?void 0:t.last_updated).getTime(),new Date(null==e?void 0:e.last_updated).getTime(),i)),last_triggered:(t,e,i)=>{var s,n,o,r;return null==(null===(s=null==t?void 0:t.attributes)||void 0===s?void 0:s.last_triggered)||null==(null===(n=null==e?void 0:e.attributes)||void 0===n?void 0:n.last_triggered)?0:(i.numeric=!0,Ct(new Date(null===(o=null==t?void 0:t.attributes)||void 0===o?void 0:o.last_triggered).getTime(),new Date(null===(r=null==e?void 0:e.attributes)||void 0===r?void 0:r.last_triggered).getTime(),i))}};function Et(t,e){return function(i,s){var n,o;return null!==(o=null===(n=Ot[e.method])||void 0===n?void 0:n.call(Ot,t.states[i.entity],t.states[s.entity],e))&&void 0!==o?o:0}}var xt="1.8.5";const jt=["domain","entity_id","state","name","group","device","area","last_changed","last_updated","last_triggered"],Pt=["none","domain","entity_id","friendly_name","state","last_changed","last_updated","last_triggered"];class $t extends nt{constructor(){super(...arguments),this._selectedTab=0,this._cardGUIMode=!0,this._cardGUIModeAvailable=!0}setConfig(t){this._config=t}_handleSwitchTab(t){this._selectedTab=parseInt(t.detail.index,10)}_addFilterGroup(){var t;if(!this._config)return;const e=[...null===(t=this._config.filter)||void 0===t?void 0:t.include];e.push({domain:""});const i=Object.assign(Object.assign({},this._config.filter),{include:e});this._config=Object.assign(Object.assign({},this._config),{filter:i}),this.dispatchEvent(new CustomEvent("config-changed",{detail:{config:this._config}}))}_deleteFilterGroup(t){var e;if(!this._config)return;const i=[...null===(e=this._config.filter)||void 0===e?void 0:e.include];i.splice(t,1);const s=Object.assign(Object.assign({},this._config.filter),{include:i});this._config=Object.assign(Object.assign({},this._config),{filter:s}),this.dispatchEvent(new CustomEvent("config-changed",{detail:{config:this._config}}))}_moveFilterGroup(t,e){var i;if(!this._config)return;const s=[...null===(i=this._config.filter)||void 0===i?void 0:i.include];[s[t],s[t+e]]=[s[t+e],s[t]];const n=Object.assign(Object.assign({},this._config.filter),{include:s});this._config=Object.assign(Object.assign({},this._config),{filter:n}),this.dispatchEvent(new CustomEvent("config-changed",{detail:{config:this._config}}))}_addSpecialEntry(){var t;if(!this._config)return;const e=[...null===(t=this._config.filter)||void 0===t?void 0:t.include];e.push({type:""});const i=Object.assign(Object.assign({},this._config.filter),{include:e});this._config=Object.assign(Object.assign({},this._config),{filter:i}),this.dispatchEvent(new CustomEvent("config-changed",{detail:{config:this._config}}))}async _changeSpecialEntry(t,e){var i;if(!this._config)return;const s=[...null===(i=this._config.filter)||void 0===i?void 0:i.include];s[t]=e.detail.value;const n=Object.assign(Object.assign({},this._config.filter),{include:s});this._config=Object.assign(Object.assign({},this._config),{filter:n}),this.dispatchEvent(new CustomEvent("config-changed",{detail:{config:this._config}}))}async _changeGroupOptions(t,e){var i;if(!this._config)return;const s=e.detail.value,n=[...null===(i=this._config.filter)||void 0===i?void 0:i.include];n[t]=Object.assign(Object.assign({},n[t]),{options:s});const o=Object.assign(Object.assign({},this._config.filter),{include:n});this._config=Object.assign(Object.assign({},this._config),{filter:o}),this.dispatchEvent(new CustomEvent("config-changed",{detail:{config:this._config}}))}_addFilter(t){var e;if(!this._config)return;const i=jt.find((e=>void 0===this._config.filter.include[t][e]));if(void 0===i)return;const s=[...null===(e=this._config.filter)||void 0===e?void 0:e.include];s[t]=Object.assign(Object.assign({},s[t]),{[i]:""});const n=Object.assign(Object.assign({},this._config.filter),{include:s});this._config=Object.assign(Object.assign({},this._config),{filter:n}),this.dispatchEvent(new CustomEvent("config-changed",{detail:{config:this._config}}))}_removeFilter(t,e){var i;if(!this._config)return;const s=[...null===(i=this._config.filter)||void 0===i?void 0:i.include],n=Object.assign({},s[t]);if(delete n[e],0===Object.keys(n).length)return this._deleteFilterGroup(t);s[t]=n;const o=Object.assign(Object.assign({},this._config.filter),{include:s});this._config=Object.assign(Object.assign({},this._config),{filter:o}),this.dispatchEvent(new CustomEvent("config-changed",{detail:{config:this._config}}))}_changeFilterKey(t,e,i){var s;if(!this._config)return;const n=jt[i.target.selected];if(void 0===n||n===e)return;const o=[...null===(s=this._config.filter)||void 0===s?void 0:s.include],r=Object.assign({},o[t]);if(void 0===r[e])return;r[n]=r[e],delete r[e],o[t]=r;const a=Object.assign(Object.assign({},this._config.filter),{include:o});this._config=Object.assign(Object.assign({},this._config),{filter:a}),this.dispatchEvent(new CustomEvent("config-changed",{detail:{config:this._config}}))}_changeFilterValue(t,e,i){var s;if(!this._config)return;const n=[...null===(s=this._config.filter)||void 0===s?void 0:s.include],o=Object.assign({},n[t]);o[e]=i.target.value,n[t]=o;const r=Object.assign(Object.assign({},this._config.filter),{include:n});this._config=Object.assign(Object.assign({},this._config),{filter:r}),this.dispatchEvent(new CustomEvent("config-changed",{detail:{config:this._config}}))}_changeSortMethod(t){if(!this._config)return;const e=Pt[t.target.selected],i=Object.assign(Object.assign({},this._config.sort),{method:e});this._config=Object.assign(Object.assign({},this._config),{sort:i}),this.dispatchEvent(new CustomEvent("config-changed",{detail:{config:this._config}}))}_sortOptionToggle(t,e){if(!this._config)return;const i=Object.assign({},this._config.sort);i[t]=e.target.checked,this._config=Object.assign(Object.assign({},this._config),{sort:i}),this.dispatchEvent(new CustomEvent("config-changed",{detail:{config:this._config}}))}_showEmptyToggle(){if(!this._config)return;const t=!1===this._config.show_empty;this._config=Object.assign(Object.assign({},this._config),{show_empty:t}),this.dispatchEvent(new CustomEvent("config-changed",{detail:{config:this._config}}))}_changeCardParam(t){if(!this._config)return;const e=""===t.target.value||"entities"===t.target.value?void 0:t.target.value;this._config=Object.assign(Object.assign({},this._config),{card_param:e}),this.dispatchEvent(new CustomEvent("config-changed",{detail:{config:this._config}}))}_getCardConfig(){const t=Object.assign({},this._config.card);return t[this._config.card_param||"entities"]=[],t}_handleCardPicked(t){if(t.stopPropagation(),!this._config)return;const e=Object.assign({},t.detail.config);delete e.entities,this._config=Object.assign(Object.assign({},this._config),{card:e}),this.dispatchEvent(new CustomEvent("config-changed",{detail:{config:this._config}}))}_handleCardConfigChanged(t){if(t.stopPropagation(),!this._config)return;const e=Object.assign({},t.detail.config);delete e[this._config.card_param||"entities"],this._config=Object.assign(Object.assign({},this._config),{card:e}),this._cardGUIModeAvailable=t.detail.guiModeAvailable,this.dispatchEvent(new CustomEvent("config-changed",{detail:{config:this._config}}))}_deleteCard(t){this._config&&(this._config=Object.assign({},this._config),delete this._config.card,this.dispatchEvent(new CustomEvent("config-changed",{detail:{config:this._config}})))}_toggleCardMode(t){var e;null===(e=this._cardEditorEl)||void 0===e||e.toggleMode()}_cardGUIModeChanged(t){t.stopPropagation(),this._cardGUIMode=t.detail.guiMode,this._cardGUIModeAvailable=t.detail.guiModeAvailable}render(){return this.hass&&this._config?V` +function t(t,e,i,s){var n,o=arguments.length,r=o<3?e:null===s?s=Object.getOwnPropertyDescriptor(e,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)r=Reflect.decorate(t,e,i,s);else for(var a=t.length-1;a>=0;a--)(n=t[a])&&(r=(o<3?n(r):o>3?n(e,i,r):n(e,i))||r);return o>3&&r&&Object.defineProperty(e,i,r),r}const e="undefined"!=typeof window&&null!=window.customElements&&void 0!==window.customElements.polyfillWrapFlushCallback,i=(t,e,i=null)=>{for(;e!==i;){const i=e.nextSibling;t.removeChild(e),e=i}},s=`{{lit-${String(Math.random()).slice(2)}}}`,n=`\x3c!--${s}--\x3e`,o=new RegExp(`${s}|${n}`);class r{constructor(t,e){this.parts=[],this.element=e;const i=[],n=[],r=document.createTreeWalker(e.content,133,null,!1);let l=0,h=-1,u=0;const{strings:p,values:{length:f}}=t;for(;u0;){const e=p[u],i=d.exec(e)[2],s=i.toLowerCase()+"$lit$",n=t.getAttribute(s);t.removeAttribute(s);const r=n.split(o);this.parts.push({type:"attribute",index:h,name:i,strings:r}),u+=r.length-1}}"TEMPLATE"===t.tagName&&(n.push(t),r.currentNode=t.content)}else if(3===t.nodeType){const e=t.data;if(e.indexOf(s)>=0){const s=t.parentNode,n=e.split(o),r=n.length-1;for(let e=0;e{const i=t.length-e.length;return i>=0&&t.slice(i)===e},l=t=>-1!==t.index,c=()=>document.createComment(""),d=/([ \x09\x0a\x0c\x0d])([^\0-\x1F\x7F-\x9F "'>=/]+)([ \x09\x0a\x0c\x0d]*=[ \x09\x0a\x0c\x0d]*(?:[^ \x09\x0a\x0c\x0d"'`<>=]*|"[^"]*|'[^']*))$/;function h(t,e){const{element:{content:i},parts:s}=t,n=document.createTreeWalker(i,133,null,!1);let o=p(s),r=s[o],a=-1,l=0;const c=[];let d=null;for(;n.nextNode();){a++;const t=n.currentNode;for(t.previousSibling===d&&(d=null),e.has(t)&&(c.push(t),null===d&&(d=t)),null!==d&&l++;void 0!==r&&r.index===a;)r.index=null!==d?-1:r.index-l,o=p(s,o),r=s[o]}c.forEach((t=>t.parentNode.removeChild(t)))}const u=t=>{let e=11===t.nodeType?0:1;const i=document.createTreeWalker(t,133,null,!1);for(;i.nextNode();)e++;return e},p=(t,e=-1)=>{for(let i=e+1;i"function"==typeof t&&f.has(t),_={},v={};class m{constructor(t,e,i){this.__parts=[],this.template=t,this.processor=e,this.options=i}update(t){let e=0;for(const i of this.__parts)void 0!==i&&i.setValue(t[e]),e++;for(const t of this.__parts)void 0!==t&&t.commit()}_clone(){const t=e?this.template.element.content.cloneNode(!0):document.importNode(this.template.element.content,!0),i=[],s=this.template.parts,n=document.createTreeWalker(t,133,null,!1);let o,r=0,a=0,c=n.nextNode();for(;rt}),b=` ${s} `;class w{constructor(t,e,i,s){this.strings=t,this.values=e,this.type=i,this.processor=s}getHTML(){const t=this.strings.length-1;let e="",i=!1;for(let o=0;o-1||i)&&-1===t.indexOf("--\x3e",r+1);const a=d.exec(t);e+=null===a?t+(i?b:n):t.substr(0,a.index)+a[1]+a[2]+"$lit$"+a[3]+s}return e+=this.strings[t],e}getTemplateElement(){const t=document.createElement("template");let e=this.getHTML();return void 0!==y&&(e=y.createHTML(e)),t.innerHTML=e,t}}const S=t=>null===t||!("object"==typeof t||"function"==typeof t),C=t=>Array.isArray(t)||!(!t||!t[Symbol.iterator]);class O{constructor(t,e,i){this.dirty=!0,this.element=t,this.name=e,this.strings=i,this.parts=[];for(let t=0;t{try{const t={get capture(){return N=!0,!1}};window.addEventListener("test",t,t),window.removeEventListener("test",t,t)}catch(t){}})();class T{constructor(t,e,i){this.value=void 0,this.__pendingValue=void 0,this.element=t,this.eventName=e,this.eventContext=i,this.__boundHandleEvent=t=>this.handleEvent(t)}setValue(t){this.__pendingValue=t}commit(){for(;g(this.__pendingValue);){const t=this.__pendingValue;this.__pendingValue=_,t(this)}if(this.__pendingValue===_)return;const t=this.__pendingValue,e=this.value,i=null==t||null!=e&&(t.capture!==e.capture||t.once!==e.once||t.passive!==e.passive),s=null!=t&&(null==e||i);i&&this.element.removeEventListener(this.eventName,this.__boundHandleEvent,this.__options),s&&(this.__options=k(t),this.element.addEventListener(this.eventName,this.__boundHandleEvent,this.__options)),this.value=t,this.__pendingValue=_}handleEvent(t){"function"==typeof this.value?this.value.call(this.eventContext||this.element,t):this.value.handleEvent(t)}}const k=t=>t&&(N?{capture:t.capture,passive:t.passive,once:t.once}:t.capture);function A(t){let e=M.get(t.type);void 0===e&&(e={stringsArray:new WeakMap,keyString:new Map},M.set(t.type,e));let i=e.stringsArray.get(t.strings);if(void 0!==i)return i;const n=t.strings.join(s);return i=e.keyString.get(n),void 0===i&&(i=new r(t,t.getTemplateElement()),e.keyString.set(n,i)),e.stringsArray.set(t.strings,i),i}const M=new Map,U=new WeakMap;const F=new class{handleAttributeExpressions(t,e,i,s){const n=e[0];if("."===n){return new j(t,e.slice(1),i).parts}if("@"===n)return[new T(t,e.slice(1),s.eventContext)];if("?"===n)return[new P(t,e.slice(1),i)];return new O(t,e,i).parts}handleTextExpression(t){return new x(t)}};"undefined"!=typeof window&&(window.litHtmlVersions||(window.litHtmlVersions=[])).push("1.3.0");const V=(t,...e)=>new w(t,e,"html",F),I=(t,e)=>`${t}--${e}`;let R=!0;void 0===window.ShadyCSS?R=!1:void 0===window.ShadyCSS.prepareTemplateDom&&(console.warn("Incompatible ShadyCSS version detected. Please update to at least @webcomponents/webcomponentsjs@2.0.2 and @webcomponents/shadycss@1.3.1."),R=!1);const D=t=>e=>{const i=I(e.type,t);let n=M.get(i);void 0===n&&(n={stringsArray:new WeakMap,keyString:new Map},M.set(i,n));let o=n.stringsArray.get(e.strings);if(void 0!==o)return o;const a=e.strings.join(s);if(o=n.keyString.get(a),void 0===o){const i=e.getTemplateElement();R&&window.ShadyCSS.prepareTemplateDom(i,t),o=new r(e,i),n.keyString.set(a,o)}return n.stringsArray.set(e.strings,o),o},q=["html","svg"],W=new Set,G=(t,e,i)=>{W.add(t);const s=i?i.element:document.createElement("template"),n=e.querySelectorAll("style"),{length:o}=n;if(0===o)return void window.ShadyCSS.prepareTemplateStyles(s,t);const r=document.createElement("style");for(let t=0;t{q.forEach((e=>{const i=M.get(I(e,t));void 0!==i&&i.keyString.forEach((t=>{const{element:{content:e}}=t,i=new Set;Array.from(e.querySelectorAll("style")).forEach((t=>{i.add(t)})),h(t,i)}))}))})(t);const a=s.content;i?function(t,e,i=null){const{element:{content:s},parts:n}=t;if(null==i)return void s.appendChild(e);const o=document.createTreeWalker(s,133,null,!1);let r=p(n),a=0,l=-1;for(;o.nextNode();)for(l++,o.currentNode===i&&(a=u(e),i.parentNode.insertBefore(e,i));-1!==r&&n[r].index===l;){if(a>0){for(;-1!==r;)n[r].index+=a,r=p(n,r);return}r=p(n,r)}}(i,r,a.firstChild):a.insertBefore(r,a.firstChild),window.ShadyCSS.prepareTemplateStyles(s,t);const l=a.querySelector("style");if(window.ShadyCSS.nativeShadow&&null!==l)e.insertBefore(l.cloneNode(!0),e.firstChild);else if(i){a.insertBefore(r,a.firstChild);const t=new Set;t.add(r),h(i,t)}};window.JSCompiler_renameProperty=(t,e)=>t;const z={toAttribute(t,e){switch(e){case Boolean:return t?"":null;case Object:case Array:return null==t?t:JSON.stringify(t)}return t},fromAttribute(t,e){switch(e){case Boolean:return null!==t;case Number:return null===t?null:Number(t);case Object:case Array:return JSON.parse(t)}return t}},L=(t,e)=>e!==t&&(e==e||t==t),B={attribute:!0,type:String,converter:z,reflect:!1,hasChanged:L};class H extends HTMLElement{constructor(){super(),this.initialize()}static get observedAttributes(){this.finalize();const t=[];return this._classProperties.forEach(((e,i)=>{const s=this._attributeNameForProperty(i,e);void 0!==s&&(this._attributeToPropertyMap.set(s,i),t.push(s))})),t}static _ensureClassProperties(){if(!this.hasOwnProperty(JSCompiler_renameProperty("_classProperties",this))){this._classProperties=new Map;const t=Object.getPrototypeOf(this)._classProperties;void 0!==t&&t.forEach(((t,e)=>this._classProperties.set(e,t)))}}static createProperty(t,e=B){if(this._ensureClassProperties(),this._classProperties.set(t,e),e.noAccessor||this.prototype.hasOwnProperty(t))return;const i="symbol"==typeof t?Symbol():`__${t}`,s=this.getPropertyDescriptor(t,i,e);void 0!==s&&Object.defineProperty(this.prototype,t,s)}static getPropertyDescriptor(t,e,i){return{get(){return this[e]},set(s){const n=this[t];this[e]=s,this.requestUpdateInternal(t,n,i)},configurable:!0,enumerable:!0}}static getPropertyOptions(t){return this._classProperties&&this._classProperties.get(t)||B}static finalize(){const t=Object.getPrototypeOf(this);if(t.hasOwnProperty("finalized")||t.finalize(),this.finalized=!0,this._ensureClassProperties(),this._attributeToPropertyMap=new Map,this.hasOwnProperty(JSCompiler_renameProperty("properties",this))){const t=this.properties,e=[...Object.getOwnPropertyNames(t),..."function"==typeof Object.getOwnPropertySymbols?Object.getOwnPropertySymbols(t):[]];for(const i of e)this.createProperty(i,t[i])}}static _attributeNameForProperty(t,e){const i=e.attribute;return!1===i?void 0:"string"==typeof i?i:"string"==typeof t?t.toLowerCase():void 0}static _valueHasChanged(t,e,i=L){return i(t,e)}static _propertyValueFromAttribute(t,e){const i=e.type,s=e.converter||z,n="function"==typeof s?s:s.fromAttribute;return n?n(t,i):t}static _propertyValueToAttribute(t,e){if(void 0===e.reflect)return;const i=e.type,s=e.converter;return(s&&s.toAttribute||z.toAttribute)(t,i)}initialize(){this._updateState=0,this._updatePromise=new Promise((t=>this._enableUpdatingResolver=t)),this._changedProperties=new Map,this._saveInstanceProperties(),this.requestUpdateInternal()}_saveInstanceProperties(){this.constructor._classProperties.forEach(((t,e)=>{if(this.hasOwnProperty(e)){const t=this[e];delete this[e],this._instanceProperties||(this._instanceProperties=new Map),this._instanceProperties.set(e,t)}}))}_applyInstanceProperties(){this._instanceProperties.forEach(((t,e)=>this[e]=t)),this._instanceProperties=void 0}connectedCallback(){this.enableUpdating()}enableUpdating(){void 0!==this._enableUpdatingResolver&&(this._enableUpdatingResolver(),this._enableUpdatingResolver=void 0)}disconnectedCallback(){}attributeChangedCallback(t,e,i){e!==i&&this._attributeToProperty(t,i)}_propertyToAttribute(t,e,i=B){const s=this.constructor,n=s._attributeNameForProperty(t,i);if(void 0!==n){const t=s._propertyValueToAttribute(e,i);if(void 0===t)return;this._updateState=8|this._updateState,null==t?this.removeAttribute(n):this.setAttribute(n,t),this._updateState=-9&this._updateState}}_attributeToProperty(t,e){if(8&this._updateState)return;const i=this.constructor,s=i._attributeToPropertyMap.get(t);if(void 0!==s){const t=i.getPropertyOptions(s);this._updateState=16|this._updateState,this[s]=i._propertyValueFromAttribute(e,t),this._updateState=-17&this._updateState}}requestUpdateInternal(t,e,i){let s=!0;if(void 0!==t){const n=this.constructor;i=i||n.getPropertyOptions(t),n._valueHasChanged(this[t],e,i.hasChanged)?(this._changedProperties.has(t)||this._changedProperties.set(t,e),!0!==i.reflect||16&this._updateState||(void 0===this._reflectingProperties&&(this._reflectingProperties=new Map),this._reflectingProperties.set(t,i))):s=!1}!this._hasRequestedUpdate&&s&&(this._updatePromise=this._enqueueUpdate())}requestUpdate(t,e){return this.requestUpdateInternal(t,e),this.updateComplete}async _enqueueUpdate(){this._updateState=4|this._updateState;try{await this._updatePromise}catch(t){}const t=this.performUpdate();return null!=t&&await t,!this._hasRequestedUpdate}get _hasRequestedUpdate(){return 4&this._updateState}get hasUpdated(){return 1&this._updateState}performUpdate(){if(!this._hasRequestedUpdate)return;this._instanceProperties&&this._applyInstanceProperties();let t=!1;const e=this._changedProperties;try{t=this.shouldUpdate(e),t?this.update(e):this._markUpdated()}catch(e){throw t=!1,this._markUpdated(),e}t&&(1&this._updateState||(this._updateState=1|this._updateState,this.firstUpdated(e)),this.updated(e))}_markUpdated(){this._changedProperties=new Map,this._updateState=-5&this._updateState}get updateComplete(){return this._getUpdateComplete()}_getUpdateComplete(){return this._updatePromise}shouldUpdate(t){return!0}update(t){void 0!==this._reflectingProperties&&this._reflectingProperties.size>0&&(this._reflectingProperties.forEach(((t,e)=>this._propertyToAttribute(e,this[e],t))),this._reflectingProperties=void 0),this._markUpdated()}updated(t){}firstUpdated(t){}}H.finalized=!0;const J=(t,e)=>"method"===e.kind&&e.descriptor&&!("value"in e.descriptor)?Object.assign(Object.assign({},e),{finisher(i){i.createProperty(e.key,t)}}):{kind:"field",key:Symbol(),placement:"own",descriptor:{},initializer(){"function"==typeof e.initializer&&(this[e.key]=e.initializer.call(this))},finisher(i){i.createProperty(e.key,t)}};function K(t){return(e,i)=>void 0!==i?((t,e,i)=>{e.constructor.createProperty(i,t)})(t,e,i):J(t,e)}function Y(t){return K({attribute:!1,hasChanged:null==t?void 0:t.hasChanged})}const Q=(t,e,i)=>{Object.defineProperty(e,i,t)},X=(t,e)=>({kind:"method",placement:"prototype",key:e.key,descriptor:t}),Z=window.ShadowRoot&&(void 0===window.ShadyCSS||window.ShadyCSS.nativeShadow)&&"adoptedStyleSheets"in Document.prototype&&"replace"in CSSStyleSheet.prototype,tt=Symbol();class et{constructor(t,e){if(e!==tt)throw new Error("CSSResult is not constructable. Use `unsafeCSS` or `css` instead.");this.cssText=t}get styleSheet(){return void 0===this._styleSheet&&(Z?(this._styleSheet=new CSSStyleSheet,this._styleSheet.replaceSync(this.cssText)):this._styleSheet=null),this._styleSheet}toString(){return this.cssText}}const it=(t,...e)=>{const i=e.reduce(((e,i,s)=>e+(t=>{if(t instanceof et)return t.cssText;if("number"==typeof t)return t;throw new Error(`Value passed to 'css' function must be a 'css' function result: ${t}. Use 'unsafeCSS' to pass non-literal values, but\n take care to ensure page security.`)})(i)+t[s+1]),t[0]);return new et(i,tt)};(window.litElementVersions||(window.litElementVersions=[])).push("2.4.0");const st={};class nt extends H{static getStyles(){return this.styles}static _getUniqueStyles(){if(this.hasOwnProperty(JSCompiler_renameProperty("_styles",this)))return;const t=this.getStyles();if(Array.isArray(t)){const e=(t,i)=>t.reduceRight(((t,i)=>Array.isArray(i)?e(i,t):(t.add(i),t)),i),i=e(t,new Set),s=[];i.forEach((t=>s.unshift(t))),this._styles=s}else this._styles=void 0===t?[]:[t];this._styles=this._styles.map((t=>{if(t instanceof CSSStyleSheet&&!Z){const e=Array.prototype.slice.call(t.cssRules).reduce(((t,e)=>t+e.cssText),"");return new et(String(e),tt)}return t}))}initialize(){super.initialize(),this.constructor._getUniqueStyles(),this.renderRoot=this.createRenderRoot(),window.ShadowRoot&&this.renderRoot instanceof window.ShadowRoot&&this.adoptStyles()}createRenderRoot(){return this.attachShadow({mode:"open"})}adoptStyles(){const t=this.constructor._styles;0!==t.length&&(void 0===window.ShadyCSS||window.ShadyCSS.nativeShadow?Z?this.renderRoot.adoptedStyleSheets=t.map((t=>t instanceof CSSStyleSheet?t:t.styleSheet)):this._needsShimAdoptedStyleSheets=!0:window.ShadyCSS.ScopingShim.prepareAdoptedCssText(t.map((t=>t.cssText)),this.localName))}connectedCallback(){super.connectedCallback(),this.hasUpdated&&void 0!==window.ShadyCSS&&window.ShadyCSS.styleElement(this)}update(t){const e=this.render();super.update(t),e!==st&&this.constructor.render(e,this.renderRoot,{scopeName:this.localName,eventContext:this}),this._needsShimAdoptedStyleSheets&&(this._needsShimAdoptedStyleSheets=!1,this.constructor._styles.forEach((t=>{const e=document.createElement("style");e.textContent=t.cssText,this.renderRoot.appendChild(e)})))}render(){return st}}function ot(){return document.querySelector("hc-main")?document.querySelector("hc-main").hass:document.querySelector("home-assistant")?document.querySelector("home-assistant").hass:void 0}nt.finalized=!0,nt.render=(t,e,s)=>{if(!s||"object"!=typeof s||!s.scopeName)throw new Error("The `scopeName` option is required.");const n=s.scopeName,o=U.has(e),r=R&&11===e.nodeType&&!!e.host,a=r&&!W.has(n),l=a?document.createDocumentFragment():e;if(((t,e,s)=>{let n=U.get(e);void 0===n&&(i(e,e.firstChild),U.set(e,n=new x(Object.assign({templateFactory:A},s))),n.appendInto(e)),n.setValue(t),n.commit()})(t,l,Object.assign({templateFactory:D(n)},s)),a){const t=U.get(l);U.delete(l);const s=t.value instanceof m?t.value.template:void 0;G(n,l,s),i(e,e.firstChild),e.appendChild(l),U.set(e,t)}!o&&r&&window.ShadyCSS.styleElement(e.host)};const rt="lovelace-player-device-id";function at(){if(!localStorage[rt]){const t=()=>Math.floor(1e5*(1+Math.random())).toString(16).substring(1);window.fully&&"function"==typeof fully.getDeviceId?localStorage[rt]=fully.getDeviceId():localStorage[rt]=`${t()}${t()}-${t()}${t()}`}return localStorage[rt]}let lt=at();const ct=new URLSearchParams(window.location.search);var dt;function ht(t){return!!String(t).includes("{%")||(!!String(t).includes("{{")||void 0)}ct.get("deviceID")&&null!==(dt=ct.get("deviceID"))&&("clear"===dt?localStorage.removeItem(rt):localStorage[rt]=dt,lt=at()),window.cardMod_template_cache=window.cardMod_template_cache||{};const ut=window.cardMod_template_cache;async function pt(t,e,i){const s=ot().connection,n=JSON.stringify([e,i]);let o=ut[n];o?(o.callbacks.has(t)||ft(t),t(o.value),o.callbacks.add(t)):(ft(t),t(""),i=Object.assign({user:ot().user.name,browser:lt,hash:location.hash.substr(1)||""},i),ut[n]=o={template:e,variables:i,value:"",callbacks:new Set([t]),unsubscribe:s.subscribeMessage((t=>function(t,e){const i=ut[t];i&&(i.value=e.result,i.callbacks.forEach((t=>t(e.result))))}(n,t)),{type:"render_template",template:e,variables:i})})}async function ft(t){let e;for(const[i,s]of Object.entries(ut))if(s.callbacks.has(t)){s.callbacks.delete(t),0==s.callbacks.size&&(e=s.unsubscribe,delete ut[i]);break}e&&await(await e)()}var gt;function _t(t,e){if("string"==typeof e&&"string"==typeof t&&(t.startsWith("/")&&t.endsWith("/")||-1!==t.indexOf("*"))){return t.startsWith("/")||(t=`/^${t=t.replace(/\./g,".").replace(/\*/g,".*")}$/`),new RegExp(t.slice(1,-1)).test(e)}if("string"==typeof t){if(t.startsWith("<="))return parseFloat(e)<=parseFloat(t.substr(2));if(t.startsWith(">="))return parseFloat(e)>=parseFloat(t.substr(2));if(t.startsWith("<"))return parseFloat(e)"))return parseFloat(e)>parseFloat(t.substr(1));if(t.startsWith("!"))return parseFloat(e)!=parseFloat(t.substr(1));if(t.startsWith("="))return parseFloat(e)==parseFloat(t.substr(1))}return t===e}window.autoEntities_cache=null!==(gt=window.autoEntities_cache)&&void 0!==gt?gt:{};const vt=window.autoEntities_cache;async function mt(t){var e;return vt.areas=null!==(e=vt.areas)&&void 0!==e?e:await t.callWS({type:"config/area_registry/list"}),vt.areas}async function yt(t){var e;return vt.devices=null!==(e=vt.devices)&&void 0!==e?e:await t.callWS({type:"config/device_registry/list"}),vt.devices}async function bt(t){var e;return vt.entities=null!==(e=vt.entities)&&void 0!==e?e:await t.callWS({type:"config/entity_registry/list"}),vt.entities}const wt={options:async()=>!0,sort:async()=>!0,domain:async(t,e,i)=>_t(e,i.entity_id.split(".")[0]),entity_id:async(t,e,i)=>_t(e,i.entity_id),state:async(t,e,i)=>_t(e,i.state),name:async(t,e,i)=>{var s;return _t(e,null===(s=i.attributes)||void 0===s?void 0:s.friendly_name)},group:async(t,e,i)=>{var s,n,o;return null===(o=null===(n=null===(s=t.states[e])||void 0===s?void 0:s.attributes)||void 0===n?void 0:n.entity_id)||void 0===o?void 0:o.includes(i.entity_id)},attributes:async(t,e,i)=>{for(const[t,s]of Object.entries(e)){let e=t.split(" ")[0],n=i.attributes;for(const t of e.split(":"))n=n?n[t]:void 0;if(void 0===n||!_t(s,n))return!1}return!0},not:async(t,e,i)=>!await St(t,e,i.entity_id),or:async(t,e,i)=>{for(const s of e)if(await St(t,s,i.entity_id))return!0;return!1},device:async(t,e,i)=>{const s=(await bt(t)).find((t=>t.entity_id===i.entity_id));if(!s)return!1;const n=(await yt(t)).find((t=>t.id===s.device_id));return!!n&&(_t(e,n.name_by_user)||_t(e,n.name))},area:async(t,e,i)=>{const s=(await bt(t)).find((t=>t.entity_id===i.entity_id));if(!s)return!1;let n=(await mt(t)).find((t=>t.area_id===s.area_id));if(n)return _t(e,n.name);const o=(await yt(t)).find((t=>t.id===s.device_id));return!!o&&(n=(await mt(t)).find((t=>t.area_id===o.area_id)),!!n&&_t(e,n.name))},last_changed:async(t,e,i)=>_t(e,((new Date).getTime()-new Date(i.last_changed).getTime())/6e4),last_updated:async(t,e,i)=>_t(e,((new Date).getTime()-new Date(i.last_updated).getTime())/6e4),last_triggered:async(t,e,i)=>{if(null==i.attributes.last_triggered)return!1;return _t(e,((new Date).getTime()-new Date(i.attributes.last_triggered).getTime())/6e4)}};async function St(t,e,i){var s;if(!t.states[i])return!1;for(let[n,o]of Object.entries(e))if(n=n.trim().split(" ")[0].trim(),!await(null===(s=wt[n])||void 0===s?void 0:s.call(wt,t,o,t.states[i])))return!1;return!0}function Ct(t,e,i){var s,n,o,r;const[a,l]=i.reverse?[-1,1]:[1,-1];return i.ignore_case&&(t=null!==(n=null===(s=null==t?void 0:t.toLowerCase)||void 0===s?void 0:s.call(t))&&void 0!==n?n:t,e=null!==(r=null===(o=null==e?void 0:e.toLowerCase)||void 0===o?void 0:o.call(e))&&void 0!==r?r:e),i.numeric&&(isNaN(parseFloat(t))&&isNaN(parseFloat(e))||(t=isNaN(parseFloat(t))?void 0:parseFloat(t),e=isNaN(parseFloat(e))?void 0:parseFloat(e))),void 0===t&&void 0===e?0:void 0===t?a:void 0===e?l:(i.reverse?-1:1)*String(t).localeCompare(String(e),void 0,i)}const Ot={none:()=>0,domain:(t,e,i)=>{var s,n;return Ct(null===(s=null==t?void 0:t.entity_id)||void 0===s?void 0:s.split(".")[0],null===(n=null==e?void 0:e.entity_id)||void 0===n?void 0:n.split(".")[0],i)},entity_id:(t,e,i)=>Ct(null==t?void 0:t.entity_id,null==e?void 0:e.entity_id,i),friendly_name:(t,e,i)=>{var s,n,o,r;return Ct((null===(s=null==t?void 0:t.attributes)||void 0===s?void 0:s.friendly_name)||(null===(n=null==t?void 0:t.entity_id)||void 0===n?void 0:n.split(".")[1]),(null===(o=null==e?void 0:e.attributes)||void 0===o?void 0:o.friendly_name)||(null===(r=null==e?void 0:e.entity_id)||void 0===r?void 0:r.split(".")[1]),i)},name:(t,e,i)=>{var s,n,o,r;return Ct((null===(s=null==t?void 0:t.attributes)||void 0===s?void 0:s.friendly_name)||(null===(n=null==t?void 0:t.entity_id)||void 0===n?void 0:n.split(".")[1]),(null===(o=null==e?void 0:e.attributes)||void 0===o?void 0:o.friendly_name)||(null===(r=null==e?void 0:e.entity_id)||void 0===r?void 0:r.split(".")[1]),i)},state:(t,e,i)=>Ct(null==t?void 0:t.state,null==e?void 0:e.state,i),attribute:(t,e,i)=>{var s;const[n,o]=(null==i?void 0:i.reverse)?[-1,1]:[1,-1];let r=null==t?void 0:t.attributes,a=null==e?void 0:e.attributes;for(const t of null===(s=null==i?void 0:i.attribute)||void 0===s?void 0:s.split(":")){if(void 0===r&&void 0===a)return 0;if(void 0===r)return n;if(void 0===a)return o;[r,a]=[r[t],a[t]]}return Ct(r,a,i)},last_changed:(t,e,i)=>{const[s,n]=(null==i?void 0:i.reverse)?[-1,1]:[1,-1];return null==(null==t?void 0:t.last_changed)&&null==(null==e?void 0:e.last_changed)?0:null==(null==t?void 0:t.last_changed)?s:null==(null==e?void 0:e.last_changed)?n:(i.numeric=!0,Ct(new Date(null==t?void 0:t.last_changed).getTime(),new Date(null==e?void 0:e.last_changed).getTime(),i))},last_updated:(t,e,i)=>{const[s,n]=(null==i?void 0:i.reverse)?[-1,1]:[1,-1];return null==(null==t?void 0:t.last_updated)&&null==(null==e?void 0:e.last_updated)?0:null==(null==t?void 0:t.last_updated)?s:null==(null==e?void 0:e.last_updated)?n:(i.numeric=!0,Ct(new Date(null==t?void 0:t.last_updated).getTime(),new Date(null==e?void 0:e.last_updated).getTime(),i))},last_triggered:(t,e,i)=>{var s,n,o,r,a,l;const[c,d]=(null==i?void 0:i.reverse)?[-1,1]:[1,-1];return null==(null===(s=null==t?void 0:t.attributes)||void 0===s?void 0:s.last_triggered)&&null==(null===(n=null==e?void 0:e.attributes)||void 0===n?void 0:n.last_triggered)?0:null==(null===(o=null==t?void 0:t.attributes)||void 0===o?void 0:o.last_triggered)?c:null==(null===(r=null==e?void 0:e.attributes)||void 0===r?void 0:r.last_triggered)?d:(i.numeric=!0,Ct(new Date(null===(a=null==t?void 0:t.attributes)||void 0===a?void 0:a.last_triggered).getTime(),new Date(null===(l=null==e?void 0:e.attributes)||void 0===l?void 0:l.last_triggered).getTime(),i))}};function Et(t,e){return function(i,s){var n,o;return null!==(o=null===(n=Ot[e.method])||void 0===n?void 0:n.call(Ot,t.states[i.entity],t.states[s.entity],e))&&void 0!==o?o:0}}var xt="1.9.1";const Pt=["domain","entity_id","state","name","group","device","area","last_changed","last_updated","last_triggered"],jt=["none","domain","entity_id","friendly_name","state","last_changed","last_updated","last_triggered"];class $t extends nt{constructor(){super(...arguments),this._selectedTab=0,this._cardGUIMode=!0,this._cardGUIModeAvailable=!0}setConfig(t){this._config=t}_handleSwitchTab(t){this._selectedTab=parseInt(t.detail.index,10)}_addFilterGroup(){var t;if(!this._config)return;const e=[...null===(t=this._config.filter)||void 0===t?void 0:t.include];e.push({domain:""});const i=Object.assign(Object.assign({},this._config.filter),{include:e});this._config=Object.assign(Object.assign({},this._config),{filter:i}),this.dispatchEvent(new CustomEvent("config-changed",{detail:{config:this._config}}))}_deleteFilterGroup(t){var e;if(!this._config)return;const i=[...null===(e=this._config.filter)||void 0===e?void 0:e.include];i.splice(t,1);const s=Object.assign(Object.assign({},this._config.filter),{include:i});this._config=Object.assign(Object.assign({},this._config),{filter:s}),this.dispatchEvent(new CustomEvent("config-changed",{detail:{config:this._config}}))}_moveFilterGroup(t,e){var i;if(!this._config)return;const s=[...null===(i=this._config.filter)||void 0===i?void 0:i.include];[s[t],s[t+e]]=[s[t+e],s[t]];const n=Object.assign(Object.assign({},this._config.filter),{include:s});this._config=Object.assign(Object.assign({},this._config),{filter:n}),this.dispatchEvent(new CustomEvent("config-changed",{detail:{config:this._config}}))}_addSpecialEntry(){var t;if(!this._config)return;const e=[...null===(t=this._config.filter)||void 0===t?void 0:t.include];e.push({type:""});const i=Object.assign(Object.assign({},this._config.filter),{include:e});this._config=Object.assign(Object.assign({},this._config),{filter:i}),this.dispatchEvent(new CustomEvent("config-changed",{detail:{config:this._config}}))}async _changeSpecialEntry(t,e){var i;if(!this._config)return;const s=[...null===(i=this._config.filter)||void 0===i?void 0:i.include];s[t]=e.detail.value;const n=Object.assign(Object.assign({},this._config.filter),{include:s});this._config=Object.assign(Object.assign({},this._config),{filter:n}),this.dispatchEvent(new CustomEvent("config-changed",{detail:{config:this._config}}))}async _changeGroupOptions(t,e){var i;if(!this._config)return;const s=e.detail.value,n=[...null===(i=this._config.filter)||void 0===i?void 0:i.include];n[t]=Object.assign(Object.assign({},n[t]),{options:s});const o=Object.assign(Object.assign({},this._config.filter),{include:n});this._config=Object.assign(Object.assign({},this._config),{filter:o}),this.dispatchEvent(new CustomEvent("config-changed",{detail:{config:this._config}}))}_addFilter(t){var e;if(!this._config)return;const i=Pt.find((e=>void 0===this._config.filter.include[t][e]));if(void 0===i)return;const s=[...null===(e=this._config.filter)||void 0===e?void 0:e.include];s[t]=Object.assign(Object.assign({},s[t]),{[i]:""});const n=Object.assign(Object.assign({},this._config.filter),{include:s});this._config=Object.assign(Object.assign({},this._config),{filter:n}),this.dispatchEvent(new CustomEvent("config-changed",{detail:{config:this._config}}))}_removeFilter(t,e){var i;if(!this._config)return;const s=[...null===(i=this._config.filter)||void 0===i?void 0:i.include],n=Object.assign({},s[t]);if(delete n[e],0===Object.keys(n).length)return this._deleteFilterGroup(t);s[t]=n;const o=Object.assign(Object.assign({},this._config.filter),{include:s});this._config=Object.assign(Object.assign({},this._config),{filter:o}),this.dispatchEvent(new CustomEvent("config-changed",{detail:{config:this._config}}))}_changeFilterKey(t,e,i){var s;if(!this._config)return;const n=Pt[i.target.selected];if(void 0===n||n===e)return;const o=[...null===(s=this._config.filter)||void 0===s?void 0:s.include],r=Object.assign({},o[t]);if(void 0===r[e])return;r[n]=r[e],delete r[e],o[t]=r;const a=Object.assign(Object.assign({},this._config.filter),{include:o});this._config=Object.assign(Object.assign({},this._config),{filter:a}),this.dispatchEvent(new CustomEvent("config-changed",{detail:{config:this._config}}))}_changeFilterValue(t,e,i){var s;if(!this._config)return;const n=[...null===(s=this._config.filter)||void 0===s?void 0:s.include],o=Object.assign({},n[t]);o[e]=i.target.value,n[t]=o;const r=Object.assign(Object.assign({},this._config.filter),{include:n});this._config=Object.assign(Object.assign({},this._config),{filter:r}),this.dispatchEvent(new CustomEvent("config-changed",{detail:{config:this._config}}))}_changeSortMethod(t){if(!this._config)return;const e=jt[t.target.selected],i=Object.assign(Object.assign({},this._config.sort),{method:e});this._config=Object.assign(Object.assign({},this._config),{sort:i}),this.dispatchEvent(new CustomEvent("config-changed",{detail:{config:this._config}}))}_sortOptionToggle(t,e){if(!this._config)return;const i=Object.assign({},this._config.sort);i[t]=e.target.checked,this._config=Object.assign(Object.assign({},this._config),{sort:i}),this.dispatchEvent(new CustomEvent("config-changed",{detail:{config:this._config}}))}_showEmptyToggle(){if(!this._config)return;const t=!1===this._config.show_empty;this._config=Object.assign(Object.assign({},this._config),{show_empty:t}),this.dispatchEvent(new CustomEvent("config-changed",{detail:{config:this._config}}))}_changeCardParam(t){if(!this._config)return;const e=""===t.target.value||"entities"===t.target.value?void 0:t.target.value;this._config=Object.assign(Object.assign({},this._config),{card_param:e}),this.dispatchEvent(new CustomEvent("config-changed",{detail:{config:this._config}}))}_getCardConfig(){const t=Object.assign({},this._config.card);return t[this._config.card_param||"entities"]=[],t}_handleCardPicked(t){if(t.stopPropagation(),!this._config)return;const e=Object.assign({},t.detail.config);delete e.entities,this._config=Object.assign(Object.assign({},this._config),{card:e}),this.dispatchEvent(new CustomEvent("config-changed",{detail:{config:this._config}}))}_handleCardConfigChanged(t){if(t.stopPropagation(),!this._config)return;const e=Object.assign({},t.detail.config);delete e[this._config.card_param||"entities"],this._config=Object.assign(Object.assign({},this._config),{card:e}),this._cardGUIModeAvailable=t.detail.guiModeAvailable,this.dispatchEvent(new CustomEvent("config-changed",{detail:{config:this._config}}))}_deleteCard(t){this._config&&(this._config=Object.assign({},this._config),delete this._config.card,this.dispatchEvent(new CustomEvent("config-changed",{detail:{config:this._config}})))}_toggleCardMode(t){var e;null===(e=this._cardEditorEl)||void 0===e||e.toggleMode()}_cardGUIModeChanged(t){t.stopPropagation(),this._cardGUIMode=t.detail.guiMode,this._cardGUIModeAvailable=t.detail.guiModeAvailable}render(){return this.hass&&this._config?V`
- `:V``}_renderFilterEditor(){return V` + `:V``}_renderFilterEditor(){var t;return(null===(t=this._config.filter)||void 0===t?void 0:t.template)||this._config.entities?V` +
+

+ Your filter method is not handled by the GUI editor. +

+

Please switch to the CODE EDITOR to access all options.

+
+ `:V` ${this._config.filter.include.map(((t,e)=>V`
@@ -38,15 +45,15 @@ function t(t,e,i,s){var n,o=arguments.length,r=o<3?e:null===s?s=Object.getOwnPro
${void 0===t.type?V` ${Object.entries(t).map((([t,i],s)=>V` - ${jt.includes(t)?V` + ${Pt.includes(t)?V`
this._changeFilterKey(e,t,i)} > - ${jt.map((t=>V` ${t} `))} + ${Pt.map((t=>V` ${t} `))} Add non-filter entry - `}_renderSortEditor(){var t,e,i,s;return V` + `}_renderSortEditor(){var t,e,i,s,n;return V`
- ${(null===(t=this._config.sort)||void 0===t?void 0:t.method)&&!Pt.includes(this._config.sort.method)?V`

+ ${(null===(t=this._config.sort)||void 0===t?void 0:t.method)&&!jt.includes(this._config.sort.method)?V`

Your sort method is not handled by the GUI editor.

Please switch to the CODE EDITOR to access all options.

`:V` Method: - ${Pt.map((t=>V` ${t} `))} + ${jt.map((t=>V` ${t} `))} - - this._sortOptionToggle("reverse",t)} - > - +

+ + this._sortOptionToggle("reverse",t)} + > + +

+

+ + this._sortOptionToggle("numeric",t)} + > + +

`}
`}_renderCardEditor(){var t;return V` @@ -191,4 +207,4 @@ function t(t,e,i,s){var n,o=arguments.length,r=o<3?e:null===s?s=Object.getOwnPro .gui-mode-button { margin-right: auto; } - `]}}function Nt(t,e){if(t===e)return!0;if(typeof t!=typeof e)return!1;if(!(t instanceof Object&&e instanceof Object))return!1;for(const i in t)if(t.hasOwnProperty(i)){if(!e.hasOwnProperty(i))return!1;if(t[i]!==e[i]){if("object"!=typeof t[i])return!1;if(!Nt(t[i],e[i]))return!1}}for(const i in e)if(e.hasOwnProperty(i)&&!t.hasOwnProperty(i))return!1;return!0}t([Y()],$t.prototype,"_config",void 0),t([K()],$t.prototype,"lovelace",void 0),t([K()],$t.prototype,"hass",void 0),t([Y()],$t.prototype,"_selectedTab",void 0),t([Y()],$t.prototype,"_cardGUIMode",void 0),t([Y()],$t.prototype,"_cardGUIModeAvailable",void 0),t([function(t,e){return(i,s)=>{const n={get(){return this.renderRoot.querySelector(t)},enumerable:!0,configurable:!0};if(e){const e="symbol"==typeof s?Symbol():`__${s}`;n.get=function(){return void 0===this[e]&&(this[e]=this.renderRoot.querySelector(t)),this[e]}}return void 0!==s?Q(n,i,s):X(n,i)}}("hui-card-element-editor")],$t.prototype,"_cardEditorEl",void 0),customElements.define("auto-entities-editor",$t),window.customCards=window.customCards||[],window.customCards.push({type:"auto-entities",name:"Auto Entities",preview:!1,description:"Entity Filter on Steroids. Auto Entities allows you to fill other cards with entities automatically, based on a number of attributes."});class Tt extends nt{constructor(){super(...arguments),this._updateCooldown={timer:void 0,rerun:!1},this._renderer=t=>{this._template="string"==typeof t?t.split(/[\s,]+/):t}}static getConfigElement(){return document.createElement("auto-entities-editor")}static getStubConfig(){return{card:{type:"entities"},filter:{include:[],exclude:[]}}}setConfig(t){var e,i;if(!t)throw new Error("No configuration.");if(!(null===(e=t.card)||void 0===e?void 0:e.type))throw new Error("No card type specified.");if(!t.filter&&!t.entities)throw new Error("No filters specified.");t=JSON.parse(JSON.stringify(t)),this._config=t,(null===(i=this._config.filter)||void 0===i?void 0:i.template)&&ht(this._config.filter.template)&&pt(this._renderer,this._config.filter.template,{config:t}),this._cardBuilt=new Promise((t=>this._cardBuiltResolve=t)),queueMicrotask((()=>this.update_all()))}connectedCallback(){var t,e;super.connectedCallback(),(null===(e=null===(t=this._config)||void 0===t?void 0:t.filter)||void 0===e?void 0:e.template)&&ht(this._config.filter.template)&&pt(this._renderer,this._config.filter.template,{config:this._config})}disconnectedCallback(){super.disconnectedCallback(),ft(this._renderer)}async update_all(){if(this.card&&(this.card.hass=this.hass),this._updateCooldown.timer)return void(this._updateCooldown.rerun=!0);this._updateCooldown.rerun=!1,this._updateCooldown.timer=window.setTimeout((()=>{this._updateCooldown.timer=void 0,this._updateCooldown.rerun&&this.update_all()}),500);const t=await this.update_entities();this.update_card(t)}async update_card(t){var e,i;if(this._entities&&Nt(t,this._entities)&&Nt(this._cardConfig,this._config.card))return;const s=(null===(e=this._cardConfig)||void 0===e?void 0:e.type)!==this._config.card.type;this._entities=t,this._cardConfig=JSON.parse(JSON.stringify(this._config.card));const n=Object.assign({[this._config.card_param||"entities"]:t},this._config.card);if(!this.card||s){const t=await window.loadCardHelpers();this.card=await t.createCardElement(n)}else this.card.setConfig(n);null===(i=this._cardBuiltResolve)||void 0===i||i.call(this),this.card.hass=this.hass;const o=0===t.length&&!1===this._config.show_empty;this.style.display=o?"none":null,this.style.margin=o?"0":null,this.card.requestUpdate&&(await this.updateComplete,this.card.requestUpdate())}async update_entities(){var t,e,i;const s=t=>t?"string"==typeof t?{entity:t.trim()}:t:null;let n=[...(null===(e=null===(t=this._config)||void 0===t?void 0:t.entities)||void 0===e?void 0:e.map(s))||[]];if(!this.hass||!this._config.filter)return n;if(this._template&&(n=n.concat(this._template.map(s))),n=n.filter(Boolean),this._config.filter.include){const t=Object.keys(this.hass.states).map(s);for(const e of this._config.filter.include){if(e.type){n.push(e);continue}let i=[];for(const s of t)await St(this.hass,e,s.entity)&&i.push(JSON.parse(JSON.stringify(Object.assign(Object.assign({},s),e.options)).replace(/this.entity_id/g,s.entity)));e.sort&&(i=i.sort(Et(this.hass,e.sort))),n=n.concat(i)}}if(this._config.filter.exclude)for(const t of this._config.filter.exclude){const e=[];for(const i of n)void 0!==i.entity&&await St(this.hass,t,i.entity)||e.push(i);n=e}if(this._config.sort&&(n=n.sort(Et(this.hass,this._config.sort)),this._config.sort.count)){const t=null!==(i=this._config.sort.first)&&void 0!==i?i:0;n=n.slice(t,t+this._config.sort.count)}if(this._config.unique){let t=[];for(const e of n)"entity"===this._config.unique&&t.some((t=>t.entity===e.entity))||t.some((t=>Nt(t,e)))||t.push(e);n=t}return n}async updated(t){(t.has("_template")||t.has("hass")&&this.hass)&&queueMicrotask((()=>this.update_all()))}createRenderRoot(){return this}render(){return V`${this.card}`}async getCardSize(){var t,e;let i=0;return await this._cardBuilt,this.card&&this.card.getCardSize&&(i=await this.card.getCardSize()),1===i&&(null===(t=this._entities)||void 0===t?void 0:t.length)&&(i=this._entities.length),0===i&&(null===(e=this._config.filter)||void 0===e?void 0:e.include)&&(i=Object.keys(this._config.filter.include).length),i||5}}t([K()],Tt.prototype,"_config",void 0),t([K()],Tt.prototype,"hass",void 0),t([K()],Tt.prototype,"card",void 0),t([K()],Tt.prototype,"_template",void 0),customElements.get("auto-entities")||(customElements.define("auto-entities",Tt),console.info(`%cAUTO-ENTITIES ${xt} IS INSTALLED`,"color: green; font-weight: bold","")); + `]}}function Nt(t,e){if(t===e)return!0;if(typeof t!=typeof e)return!1;if(!(t instanceof Object&&e instanceof Object))return!1;for(const i in t)if(t.hasOwnProperty(i)){if(!e.hasOwnProperty(i))return!1;if(t[i]!==e[i]){if("object"!=typeof t[i])return!1;if(!Nt(t[i],e[i]))return!1}}for(const i in e)if(e.hasOwnProperty(i)&&!t.hasOwnProperty(i))return!1;return!0}t([Y()],$t.prototype,"_config",void 0),t([K()],$t.prototype,"lovelace",void 0),t([K()],$t.prototype,"hass",void 0),t([Y()],$t.prototype,"_selectedTab",void 0),t([Y()],$t.prototype,"_cardGUIMode",void 0),t([Y()],$t.prototype,"_cardGUIModeAvailable",void 0),t([function(t,e){return(i,s)=>{const n={get(){return this.renderRoot.querySelector(t)},enumerable:!0,configurable:!0};if(e){const e="symbol"==typeof s?Symbol():`__${s}`;n.get=function(){return void 0===this[e]&&(this[e]=this.renderRoot.querySelector(t)),this[e]}}return void 0!==s?Q(n,i,s):X(n,i)}}("hui-card-element-editor")],$t.prototype,"_cardEditorEl",void 0),customElements.define("auto-entities-editor",$t),window.customCards=window.customCards||[],window.customCards.push({type:"auto-entities",name:"Auto Entities",preview:!1,description:"Entity Filter on Steroids. Auto Entities allows you to fill other cards with entities automatically, based on a number of attributes."}),window.queueMicrotask=window.queueMicrotask||(t=>window.setTimeout(t,1));class Tt extends nt{constructor(){super(...arguments),this._updateCooldown={timer:void 0,rerun:!1},this._renderer=t=>{this._template="string"==typeof t?t.split(/[\s,]+/):t}}static getConfigElement(){return document.createElement("auto-entities-editor")}static getStubConfig(){return{card:{type:"entities"},filter:{include:[],exclude:[]}}}setConfig(t){var e,i;if(!t)throw new Error("No configuration.");if(!(null===(e=t.card)||void 0===e?void 0:e.type))throw new Error("No card type specified.");if(!t.filter&&!t.entities)throw new Error("No filters specified.");t=JSON.parse(JSON.stringify(t)),this._config=t,(null===(i=this._config.filter)||void 0===i?void 0:i.template)&&ht(this._config.filter.template)&&pt(this._renderer,this._config.filter.template,{config:t}),this._cardBuilt=new Promise((t=>this._cardBuiltResolve=t)),queueMicrotask((()=>this.update_all()))}connectedCallback(){var t,e;super.connectedCallback(),(null===(e=null===(t=this._config)||void 0===t?void 0:t.filter)||void 0===e?void 0:e.template)&&ht(this._config.filter.template)&&pt(this._renderer,this._config.filter.template,{config:this._config})}disconnectedCallback(){super.disconnectedCallback(),ft(this._renderer)}async update_all(){if(this.card&&(this.card.hass=this.hass),this._updateCooldown.timer)return void(this._updateCooldown.rerun=!0);this._updateCooldown.rerun=!1,this._updateCooldown.timer=window.setTimeout((()=>{this._updateCooldown.timer=void 0,this._updateCooldown.rerun&&this.update_all()}),500);const t=await this.update_entities();this.update_card(t)}async update_card(t){var e,i,s;if(this._entities&&Nt(t,this._entities)&&Nt(this._cardConfig,this._config.card))return;const n=(null===(e=this._cardConfig)||void 0===e?void 0:e.type)!==this._config.card.type;this._entities=t,this._cardConfig=JSON.parse(JSON.stringify(this._config.card));const o=Object.assign({[this._config.card_param||"entities"]:t},this._config.card);if(!this.card||n){const t=await window.loadCardHelpers(),e=console.error;let s=!1;if(console.error=(...t)=>{var i,n,o,r,a,l;3===t.length&&t[2].message&&((null===(n=(i=t[2].message).startsWith)||void 0===n?void 0:n.call(i,"Entities"))||(null===(r=(o=t[2].message).startsWith)||void 0===r?void 0:r.call(o,"Either entities"))||(null===(l=(a=t[2].message).endsWith)||void 0===l?void 0:l.call(a,"entity")))?s=!0:e(...t)},this.card=await t.createCardElement(o),console.error=e,s)return this.card=void 0,this._entities=void 0,this._cardConfig=void 0,void(null===(i=this._cardBuiltResolve)||void 0===i||i.call(this))}else this.card.setConfig(o);null===(s=this._cardBuiltResolve)||void 0===s||s.call(this),this.card.hass=this.hass;const r=0===t.length&&!1===this._config.show_empty;this.style.display=r?"none":null,this.style.margin=r?"0":null,this.card.requestUpdate&&(await this.updateComplete,this.card.requestUpdate())}async update_entities(){var t,e,i,s,n,o;const r=t=>t?"string"==typeof t?{entity:t.trim()}:t:null;let a=[...(null===(e=null===(t=this._config)||void 0===t?void 0:t.entities)||void 0===e?void 0:e.map(r))||[]];if(!this.hass)return a;if(this._template&&(a=a.concat(this._template.map(r))),a=a.filter(Boolean),null===(i=this._config.filter)||void 0===i?void 0:i.include){const t=Object.keys(this.hass.states).map(r);for(const e of this._config.filter.include){if(e.type){a.push(e);continue}let i=[];for(const s of t)await St(this.hass,e,s.entity)&&i.push(JSON.parse(JSON.stringify(Object.assign(Object.assign({},s),e.options)).replace(/this.entity_id/g,s.entity)));if(e.sort&&(i=i.sort(Et(this.hass,e.sort)),e.sort.count)){const t=null!==(s=e.sort.first)&&void 0!==s?s:0;i=i.slice(t,t+e.sort.count)}a=a.concat(i)}}if(null===(n=this._config.filter)||void 0===n?void 0:n.exclude)for(const t of this._config.filter.exclude){const e=[];for(const i of a)void 0!==i.entity&&await St(this.hass,t,i.entity)||e.push(i);a=e}if(this._config.sort&&(a=a.sort(Et(this.hass,this._config.sort)),this._config.sort.count)){const t=null!==(o=this._config.sort.first)&&void 0!==o?o:0;a=a.slice(t,t+this._config.sort.count)}if(this._config.unique){let t=[];for(const e of a)"entity"===this._config.unique&&e.entity&&t.some((t=>t.entity===e.entity))||t.some((t=>Nt(t,e)))||t.push(e);a=t}return a}async updated(t){(t.has("_template")||t.has("hass")&&this.hass)&&queueMicrotask((()=>this.update_all()))}createRenderRoot(){return this}render(){return V`${this.card}`}async getCardSize(){var t,e;let i=0;return await this._cardBuilt,this.card&&this.card.getCardSize&&(i=await this.card.getCardSize()),1===i&&(null===(t=this._entities)||void 0===t?void 0:t.length)&&(i=this._entities.length),0===i&&(null===(e=this._config.filter)||void 0===e?void 0:e.include)&&(i=Object.keys(this._config.filter.include).length),i||5}}t([K()],Tt.prototype,"_config",void 0),t([K()],Tt.prototype,"hass",void 0),t([K()],Tt.prototype,"card",void 0),t([K()],Tt.prototype,"_template",void 0),customElements.get("auto-entities")||(customElements.define("auto-entities",Tt),console.info(`%cAUTO-ENTITIES ${xt} IS INSTALLED`,"color: green; font-weight: bold","")); diff --git a/www/community/lovelace-auto-entities/auto-entities.js.gz b/www/community/lovelace-auto-entities/auto-entities.js.gz index 821591ac..dd269cd5 100644 Binary files a/www/community/lovelace-auto-entities/auto-entities.js.gz and b/www/community/lovelace-auto-entities/auto-entities.js.gz differ diff --git a/www/community/lovelace-auto-entities/rollup.config.js.gz b/www/community/lovelace-auto-entities/rollup.config.js.gz index cd5a83af..9147ac96 100644 Binary files a/www/community/lovelace-auto-entities/rollup.config.js.gz and b/www/community/lovelace-auto-entities/rollup.config.js.gz differ diff --git a/www/community/lovelace-valetudo-map-card/valetudo-map-card.js b/www/community/lovelace-valetudo-map-card/valetudo-map-card.js index fb8d2ede..df232815 100644 --- a/www/community/lovelace-valetudo-map-card/valetudo-map-card.js +++ b/www/community/lovelace-valetudo-map-card/valetudo-map-card.js @@ -534,6 +534,9 @@ class ValetudoMapCard extends HTMLElement { if (this._config.virtual_wall_width === undefined) this._config.virtual_wall_width = 1; if (this._config.path_width === undefined) this._config.path_width = 1; + // Padding settings + if (this._config.left_padding === undefined) this._config.left_padding = 0; + // Scale settings if (this._config.map_scale === undefined) this._config.map_scale = 1; if (this._config.icon_scale === undefined) this._config.icon_scale = 1; @@ -632,7 +635,7 @@ class ValetudoMapCard extends HTMLElement { if(this.isPollingMap === false ) { this.isPollingMap = true; - const response = await fetch(url); + const response = await this._hass.fetchWithAuth(url); let mapData; if(!response.ok) { @@ -748,6 +751,7 @@ class ValetudoMapCard extends HTMLElement { height: ${containerHeight}px; padding-top: ${containerMinHeightPadding}px; padding-bottom: ${containerMinHeightPadding}px; + padding-left: ${this._config.left_padding}px; overflow: hidden; } #lovelaceValetudoCard { diff --git a/www/community/lovelace-valetudo-map-card/valetudo-map-card.js.gz b/www/community/lovelace-valetudo-map-card/valetudo-map-card.js.gz index caa3aed6..286d0ea6 100644 Binary files a/www/community/lovelace-valetudo-map-card/valetudo-map-card.js.gz and b/www/community/lovelace-valetudo-map-card/valetudo-map-card.js.gz differ diff --git a/www/community/numberbox-card/numberbox-card.js b/www/community/numberbox-card/numberbox-card.js index 1901168a..dfaa199c 100644 --- a/www/community/numberbox-card/numberbox-card.js +++ b/www/community/numberbox-card/numberbox-card.js @@ -1,6 +1,6 @@ ((LitElement) => { -console.info('NUMBERBOX_CARD 2.7'); +console.info('NUMBERBOX_CARD 2.9'); const html = LitElement.prototype.html; const css = LitElement.prototype.css; class NumberBox extends LitElement { @@ -98,8 +98,15 @@ publishNum(dhis){ niceNum(){ let fix=0; let v=this.pending; - if( v === false ){ v=Number(this.stateObj.state); if(isNaN(v)){return '?';}} - const stp=Number(this.stateObj.attributes.step); + if( v === false ){ + v=this.stateObj.state; + if(v=='unavailable' || ( v=='unknown' && this.config.initial === undefined ) ){return '?';} + v=Number(v); + if(isNaN(v) && this.config.initial !== undefined){ + v=Number(this.config.initial); + } + } + const stp=Number(this.stateObj.attributes.step) || 1; if( Math.round(stp) != stp ){ fix=stp.toString().split(".")[1].length || 1;} fix = v.toFixed(fix); const u=this.config.unit; @@ -214,6 +221,7 @@ setConfig(config) { icon_plus: config.icon_plus, icon_minus: config.icon_minus, delay: config.delay, + initial: config.initial, secondary_info: config.secondary_info, }; } @@ -226,7 +234,7 @@ set hass(hass) { } shouldUpdate(changedProps) { - if( changedProps.has('stateObj') || changedProps.has('pending') ){return true;} + if( changedProps.has('config') || changedProps.has('stateObj') || changedProps.has('pending') ){return true;} } static getConfigElement() { @@ -294,6 +302,13 @@ render() { @change="${this.updVal}" allow-custom-entity > + + +
- - - +